Skip to content

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

@Kingpin007

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

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    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