Skip to content

Duplicate messages with stuck streaming state after tool approval (saveStreamDeltas) #185

@christiannwamba

Description

@christiannwamba

Summary

When using @convex-dev/agent with useUIMessages hook and saveStreamDeltas: true, approving or denying a tool call results in duplicate assistant messages appearing in the UI:

  1. Stream delta message: Partial/incomplete text, stuck with status: "streaming" even after stream completes, ID has "stream" prefix
  2. MessageDoc: Complete text with status: "success", regular message ID

Closing and reopening the UI component makes the duplicate disappear and shows only the correct complete message.

Environment

  • @convex-dev/agent: 0.3.0
  • ai: 5.0.51
  • React Native mobile app
  • Using useUIMessages hook with stream: true
  • Backend uses thread.streamText() with saveStreamDeltas: true

Reproduction Steps

  1. Set up agent with human-in-the-loop tool approval flow
  2. User sends message that triggers a tool call (e.g., "Schedule a meeting")
  3. Assistant responds with tool-call, approval UI shows
  4. User approves or denies the tool call
  5. Backend saves tool result message to thread using chatAgent.saveMessage()
  6. Backend calls thread.streamText() with contextOptions: { recentMessages: 20 } and saveStreamDeltas: true
  7. Stream completes successfully on backend
  8. Frontend shows two assistant messages instead of one

Expected Behavior

After tool approval, there should be one assistant message responding to the tool result with:

  • Full text content
  • status: "success" (or appropriate final status)
  • Single message ID

Actual Behavior

Two assistant messages appear:

  1. Stream delta (incomplete/stuck):

    • Text is incomplete or partial
    • status: "streaming" (never transitions to "finished")
    • ID has "stream" prefix
  2. MessageDoc (complete):

    • Full text content
    • status: "success"
    • Regular message ID

Key Observations

Backend Logs Show Correct Behavior

[STREAM_FINISH] Stream completed: { threadId, responseMessages: 1, finishReason: 'stop' }
[STREAM_DONE] Stream consumed

[TOOL_RESULT] All messages AFTER stream: [
  { id: 'ks75jy3hx8gasg180f2mawxxrs7vdn5g', role: 'assistant', status: 'success', order: 0, stepOrder: 3 },
  { id: 'ks71dp83bxp9wtby4xtpdw6wz17vdyfb', role: 'tool', status: 'success', order: 0, stepOrder: 2 },
  { id: 'ks7av80wa18h4h7q9zebr3rgp17vdzpe', role: 'assistant', status: 'success', order: 0, stepOrder: 1 },
  { id: 'ks71qdg4fkvtv8zn24zhx8cxvx7vc2d5', role: 'user', status: 'success', order: 0, stepOrder: 0 }
]

✓ Backend has only ONE assistant message at stepOrder=3
✓ Status is correctly "success"
✓ Stream consumed successfully

Frontend Receives Duplicate

useUIMessages returns two messages:

  • One with "stream" prefix ID - partial/stuck at "streaming"
  • One with regular message ID - complete with "success"

Workaround That Proves It's Client-Side State Issue

Closing and reopening the UI component fixes the issue immediately.

This proves:

  • Backend data is correct (otherwise reopening wouldn't fix it)
  • Problem is in useUIMessages internal state management
  • Stream deltas are persisting in component state after stream finishes

Suspected Root Cause

When saveStreamDeltas: true is used:

  1. Stream deltas are saved to streamingMessages table during streaming
  2. useUIMessages merges these deltas with MessageDoc objects
  3. After stream finishes, the stream delta state doesn't update from "streaming" to "finished"
  4. Frontend continues showing both:
    • The stream delta (status: "streaming", partial text)
    • The MessageDoc (status: "success", complete text)
  5. Remounting the component reinitializes useUIMessages state, fetching fresh data and correctly showing only the MessageDoc

Additional Context

Backend Tool Result Flow

// 1. Build and save tool result message
const toolMessage = await buildToolResultMessage(ctx, input, approvalTools, spanTracker);
await chatAgent.saveMessage(ctx, { threadId, message: toolMessage });

// 2. Start new stream (no messages parameter, let context fetch from thread)
const result = await thread.streamText({
  model: "openai/gpt-4.1",
  system: systemPrompt,
  tools,
  // ... callbacks
}, {
  saveStreamDeltas: true,
  contextOptions: {
    recentMessages: 20,
  },
});

await result.consumeStream();

Frontend Query

// domains/chat/api.chat.ts
export const listThreadMessages = authorizedQuery({
  handler: async (ctx, { threadId, paginationOpts, streamArgs }) => {
    const paginated = await listUIMessages(ctx, components.agent, {
      threadId,
      paginationOpts,
    });

    const streams = await chatAgent.syncStreams(ctx, {
      threadId,
      streamArgs,
    });

    return { ...paginated, streams };
  },
});

// Frontend
const uiMessagesResult = useUIMessages(
  api.app.chat.listThreadMessages,
  { threadId },
  {
    initialNumItems: 50,
    stream: true,
  },
);

Temporary Workarounds Tried

  1. ❌ Filtering finished streams on backend
  2. ❌ Aborting old streams before new stream
  3. ❌ Updating message status before streaming
  4. Disabling saveStreamDeltas: Fixes the issue but loses real-time streaming feature

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions