Summary
When the final step of a thread.streamText(..., { saveStreamDeltas: true }) agentic loop has finishReason: "stop" with zero content (no text, no tool calls, no reasoning text), the trailing pending assistant message row is never finalized. It stays status: "pending" in the agent component's messages table indefinitely.
The stream is correctly marked finished and result.text / result.finishReason resolve cleanly, so the calling action returns success and any watchdog tied to that action exits. The orphan pending row is only discovered later by an external recovery path.
Environment
@convex-dev/agent@0.6.1
ai@^6.0.175
@openrouter/ai-sdk-provider@^2.9.0
- Model where we hit it:
google/gemini-3.5-flash via OpenRouter
Root Cause (verified against published dist/ files in 0.6.1)
dist/client/streamText.js:73-86 — onStepFinish sees the final step has no continuation, sets streamer.markFinishedExternally() and stores pendingFinalStep = step. Defers the save.
dist/client/streamText.js:115-118 — After stream consumption, calls call.save({ step: pendingFinalStep }, false, finishStreamId).
dist/client/start.js:119-122 — serializeResponseMessages(step, model, newResponseMessages) runs with newResponseMessages = allResponseMessages.slice(previousResponseMessageCount). For an empty completion, step.response.messages adds no new entries, so this slice is [].
dist/mapping.js:191-193 → 212-237 — serializeStepMessages maps over [], returns { messages: [] }.
dist/client/start.js:132-142 — addMessages is called with messages: []. In dist/component/messages.js:159, the for-loop iterates zero times. The pending row is never replaced.
dist/client/start.js:157 — pendingMessageId = undefined is set in memory, but the DB row remains status: pending with content: [].
Why generateText is not affected
generateText calls serializeNewMessagesInStep (dist/mapping.js:198-211), which has a fallback at lines 207-209:
else {
messagesToSerialize = [{ role: "assistant", content: [] }];
}
This synthetic empty-assistant message ensures the pending row gets replaced and marked status: "success". The streaming path uses serializeResponseMessages (the non-new variant) instead and has no such fallback.
Minimal Repro Idea
Any provider/model that returns finishReason: "stop" with empty content. Gemini 3.5-flash via OpenRouter does this sporadically. A mock language model returning a single { type: 'finish', finishReason: 'stop', usage: {...} } chunk with no text/tool parts should reproduce deterministically.
Summary
When the final step of a
thread.streamText(..., { saveStreamDeltas: true })agentic loop hasfinishReason: "stop"with zero content (no text, no tool calls, no reasoning text), the trailing pending assistant message row is never finalized. It staysstatus: "pending"in the agent component'smessagestable indefinitely.The stream is correctly marked
finishedandresult.text/result.finishReasonresolve cleanly, so the calling action returns success and any watchdog tied to that action exits. The orphan pending row is only discovered later by an external recovery path.Environment
@convex-dev/agent@0.6.1ai@^6.0.175@openrouter/ai-sdk-provider@^2.9.0google/gemini-3.5-flashvia OpenRouterRoot Cause (verified against published
dist/files in 0.6.1)dist/client/streamText.js:73-86—onStepFinishsees the final step has no continuation, setsstreamer.markFinishedExternally()and storespendingFinalStep = step. Defers the save.dist/client/streamText.js:115-118— After stream consumption, callscall.save({ step: pendingFinalStep }, false, finishStreamId).dist/client/start.js:119-122—serializeResponseMessages(step, model, newResponseMessages)runs withnewResponseMessages = allResponseMessages.slice(previousResponseMessageCount). For an empty completion,step.response.messagesadds no new entries, so this slice is[].dist/mapping.js:191-193 → 212-237—serializeStepMessagesmaps over[], returns{ messages: [] }.dist/client/start.js:132-142—addMessagesis called withmessages: []. Indist/component/messages.js:159, thefor-loop iterates zero times. The pending row is never replaced.dist/client/start.js:157—pendingMessageId = undefinedis set in memory, but the DB row remainsstatus: pendingwithcontent: [].Why
generateTextis not affectedgenerateTextcallsserializeNewMessagesInStep(dist/mapping.js:198-211), which has a fallback at lines 207-209:This synthetic empty-assistant message ensures the pending row gets replaced and marked
status: "success". The streaming path usesserializeResponseMessages(the non-newvariant) instead and has no such fallback.Minimal Repro Idea
Any provider/model that returns
finishReason: "stop"with empty content. Gemini 3.5-flash via OpenRouter does this sporadically. A mock language model returning a single{ type: 'finish', finishReason: 'stop', usage: {...} }chunk with no text/tool parts should reproduce deterministically.