-
Notifications
You must be signed in to change notification settings - Fork 65
Open
Description
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:
- Stream delta message: Partial/incomplete text, stuck with
status: "streaming"even after stream completes, ID has "stream" prefix - 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.0ai: 5.0.51- React Native mobile app
- Using
useUIMessageshook withstream: true - Backend uses
thread.streamText()withsaveStreamDeltas: true
Reproduction Steps
- Set up agent with human-in-the-loop tool approval flow
- User sends message that triggers a tool call (e.g., "Schedule a meeting")
- Assistant responds with tool-call, approval UI shows
- User approves or denies the tool call
- Backend saves tool result message to thread using
chatAgent.saveMessage() - Backend calls
thread.streamText()withcontextOptions: { recentMessages: 20 }andsaveStreamDeltas: true - Stream completes successfully on backend
- 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:
-
Stream delta (incomplete/stuck):
- Text is incomplete or partial
status: "streaming"(never transitions to "finished")- ID has "stream" prefix
-
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
useUIMessagesinternal state management - Stream deltas are persisting in component state after stream finishes
Suspected Root Cause
When saveStreamDeltas: true is used:
- Stream deltas are saved to
streamingMessagestable during streaming useUIMessagesmerges these deltas with MessageDoc objects- After stream finishes, the stream delta state doesn't update from
"streaming"to"finished" - Frontend continues showing both:
- The stream delta (status: "streaming", partial text)
- The MessageDoc (status: "success", complete text)
- Remounting the component reinitializes
useUIMessagesstate, 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
- ❌ Filtering finished streams on backend
- ❌ Aborting old streams before new stream
- ❌ Updating message status before streaming
- ✓ Disabling saveStreamDeltas: Fixes the issue but loses real-time streaming feature
Metadata
Metadata
Assignees
Labels
No labels