Skip to content

streamText leaves pending message row unfinalized when final step has empty content #274

Description

@Kingpin007

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)

  1. dist/client/streamText.js:73-86onStepFinish sees the final step has no continuation, sets streamer.markFinishedExternally() and stores pendingFinalStep = step. Defers the save.
  2. dist/client/streamText.js:115-118 — After stream consumption, calls call.save({ step: pendingFinalStep }, false, finishStreamId).
  3. dist/client/start.js:119-122serializeResponseMessages(step, model, newResponseMessages) runs with newResponseMessages = allResponseMessages.slice(previousResponseMessageCount). For an empty completion, step.response.messages adds no new entries, so this slice is [].
  4. dist/mapping.js:191-193 → 212-237serializeStepMessages maps over [], returns { messages: [] }.
  5. dist/client/start.js:132-142addMessages is called with messages: []. In dist/component/messages.js:159, the for-loop iterates zero times. The pending row is never replaced.
  6. dist/client/start.js:157pendingMessageId = 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.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions