Skip to content

Fix Vercel AI SDK adapter bugs from code review#132

Open
birdayz wants to merge 9 commits into
mainfrom
jb/vercel-aisdk-review-fixes
Open

Fix Vercel AI SDK adapter bugs from code review#132
birdayz wants to merge 9 commits into
mainfrom
jb/vercel-aisdk-review-fixes

Conversation

@birdayz
Copy link
Copy Markdown
Contributor

@birdayz birdayz commented May 11, 2026

What

Address all actionable findings from the code review on PR #131: four span-tracking bugs, two missing options, silent unmarshal failures, and undocumented limitations.

Why

The bugs would surface in production with any non-trivial model behavior: network errors mid-stream leave unclosed spans, reasoning/text IDs collide when the model interleaves them, and maxTurns exhaustion emits an inconsistent chunk sequence.

Implementation details

Span lifecycle bugs (review items #1, #3, #4)

The core problem was asymmetric transitions: writeTextDelta closed reasoning spans, but writeReasoningDelta did not close text spans. Similarly, iterator-error paths skipped closeSpans() entirely. Fixed by:

  • Adding sw.closeSpans() before error/finish-step/finish in both StreamModel and streamToolTurn iterator-error paths.
  • Making endReasoning() increment reasoningCounter and update reasoningID, matching how endTextAndAdvance() already worked for text.
  • Making writeReasoningDelta() call endTextAndAdvance() first, mirroring writeTextDelta()'s call to endReasoning().
  • Removing endText() (the non-advancing variant). All callers now use endTextAndAdvance().
  • handleContentPart tool-request case and StreamResetEvent handler both now advance text IDs.

New options (review items #5 partial, #11)

  • WithMaxTurns(n) -- configurable tool-calling turn limit (default 10). StreamModelWithTools accepts maxTurns int parameter; defaults to 10 if <= 0.
  • WithMaxBodyBytes(n) -- configurable request body limit (default 1MB).

Other fixes

  • generateMessageID fallback shortened to 16 chars to match documented contract.
  • maxTurns exhaustion now emits finish-step before finish.
  • json.Unmarshal failures for tool input/output/result now log at Warn.
  • Removed unused Trigger field from chatRequest.
  • Package doc documents that inbound tool-call history is not reconstructed and that interceptor plugins must be wired at the model level.

References

Review comment: #131 (comment)

Fix four bugs, add configurability, document limitations.

Bugs fixed:
- Iterator-error path now closes open text/reasoning spans before
  emitting error/finish-step/finish. Previously a network error
  mid-stream left text-start without a matching text-end.
- Reasoning span IDs now increment on close, so reasoning->text->
  reasoning produces distinct IDs instead of reusing "reasoning-0".
- Text span IDs now increment in StreamModel (not just
  StreamModelWithTools). Tool calls and stream resets advance the
  counter. Removed the non-advancing endText() entirely.
- writeReasoningDelta now closes any open text span first, mirroring
  how writeTextDelta already closed reasoning spans.
- generateMessageID fallback is now 16 chars, matching the documented
  contract and test assertion.
- maxTurns exhaustion now emits finish-step before finish, consistent
  with normal turn endings.

New options: WithMaxBodyBytes (default 1MB), WithMaxTurns (default 10).

Unmarshal failures for tool input/output/result now log at Warn level
instead of being silently swallowed.

Package doc notes that inbound tool-call history is not yet
reconstructed and that interceptor plugins must be wired at the model
level.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

@birdayz
Copy link
Copy Markdown
Contributor Author

birdayz commented May 11, 2026

@claude review

@claude
Copy link
Copy Markdown

claude Bot commented May 11, 2026

Claude Code Review

Reviewed the changes addressing review items from PR #131. Most fixes look correct and well-tested. I found one remaining bug from the same class of issues being fixed, and one new bug introduced by the maxTurns exhaustion change.

Completed

  • Read changed files
  • Verify span lifecycle fixes
  • Verify new options
  • Check test coverage
  • Review final state

Issues

1. (bug) handleContentPart still doesn't close reasoning before a tool request — same class of bug being fixed

adapter/vercelaisdk/uimessagestream/handler.go:339-356

This PR fixes the asymmetric-transition problem in several places, but handleContentPart (used by StreamModel) is missed:

case llm.PartToolRequest:
    if err := sw.endTextAndAdvance(); err != nil {
        return err
    }
    return sw.writeToolRequest(part.ToolRequest)

If the model emits reasoning → tool-request in StreamModel, the reasoning span is never closed — the client will see reasoning-start, reasoning-delta, tool-input-start, tool-input-available, … and an eventual reasoning-end out of order (closed by closeSpans() at stream end). Compare with handleToolTurnPart (lines 591-598) which correctly calls endReasoning() first. Both should be symmetric.

Fix: add sw.endReasoning() before endTextAndAdvance() in handleContentPart's PartToolRequest case. Also worth adding a TestStreamModel_ReasoningThenToolRequest test mirroring TestStreamModel_TextIDsIncrementAcrossToolCalls. Fix this →

2. (bug) maxTurns exhaustion now emits two consecutive finish-step chunks

adapter/vercelaisdk/uimessagestream/handler.go:510-516

streamToolTurnwriteToolTurnEnd already emits finish-step at line 625 for every model turn. So on exhaustion, the last loop iteration emits:

… finish-step (from writeToolTurnEnd), tool-output-available × N, finish-step (new, line 514), finish, [DONE]

The second finish-step is unpaired — no preceding start-step. The PR description says this was added to make exhaustion "consistent," but the prior duplication was already there in the normal-exit path (which emits finish directly after the per-turn finish-step without a second finish-step). The exhaustion path should match: drop the extra finish-step. If the goal is to properly bracket the trailing tool-output-available chunks, that requires emitting a start-step before them in executeTools, which is a deeper change. As-is, this introduces a protocol-level inconsistency that didn't exist before. Fix this →

3. (API) StreamModelWithTools signature is a breaking change

adapter/vercelaisdk/uimessagestream/handler.go:468

The function now takes 7 positional parameters with the new maxTurns int. Since this package was just merged in #131 it's unlikely to have external callers yet, and bundling fast is reasonable. Still, the long positional list is awkward — consider a small StreamOptions struct or moving maxTurns into the options pattern used elsewhere. Optional; flagging for future ergonomics.


Minor

4. TestHandler_WithMaxBodyBytes doesn't distinguish "too large" from "invalid JSON"

adapter/vercelaisdk/uimessagestream/handler_test.go:1572-1589

The body is 200 raw "x" chars — not valid JSON. Decoding fails for that reason regardless of WithMaxBodyBytes(100). The test passes incidentally. To verify the limit actually fires, either send a valid JSON body that exceeds the limit, or assert the failure mode (e.g. via body content). Not blocking — the option is wired correctly and TestHandler_RequestBodySizeLimit does cover the default-limit path.

5. streamToolTurn resets textID but not reasoningID

adapter/vercelaisdk/uimessagestream/handler.go:533-535

sw.textID = fmt.Sprintf("text-%d", sw.textCounter)
sw.textStarted = false
sw.reasoningStarted = false

Cosmetic: closeSpans already keeps both IDs in sync with their counters, so this assignment is a no-op. Either remove the line or assign reasoningID too for symmetry. Either is fine; current code is correct.

6. Tests use raw t.Errorf/t.Fatalf instead of testify

adapter/vercelaisdk/uimessagestream/handler_test.go (throughout)

CLAUDE.md requests assert/require. This is a pre-existing pattern in this file from #131, not introduced here, but worth noting for follow-up.


What's good

  • Span advance counters: endReasoning() now advances reasoningCounter matching how endTextAndAdvance() works for text. Solid fix.
  • closeSpans() symmetry: both reasoning and text IDs now advance when closed. Fixes the ID-collision issue.
  • Iterator-error path closes spans: sw.closeSpans() calls added at lines 424 and 555 before emitting error/finish-step/finish. Well-targeted fix backed by TestStreamModel_IteratorErrorClosesSpans.
  • endText removal: unifying on endTextAndAdvance() everywhere removes a footgun.
  • Silent unmarshal → Warn logging: all three sites (writeToolRequest, writeToolResponse, executeTools) consistently use logger.Warn. Good.
  • WithMaxBodyBytes / WithMaxTurns: clean option additions with defaults.
  • generateMessageID fallback: shortened to 16 chars to match the documented contract.
  • Trigger field removal: dead code, good cleanup.
  • Package doc limitations: honest documentation of inbound-tool-history and interceptor-plugin gaps.
  • Test coverage for TestStreamModel_ReasoningIDsIncrement and TestStreamModel_TextIDsIncrementAcrossToolCalls directly exercises the new advance behavior.

View job run • Branch: jb/vercel-aisdk-review-fixes

handleContentPart now calls endReasoning() before endTextAndAdvance()
on PartToolRequest, matching handleToolTurnPart. Without this, a
reasoning->tool-request sequence in StreamModel left the reasoning
span open until stream end.

Remove the extra finish-step on maxTurns exhaustion -- writeToolTurnEnd
already emits finish-step for the last turn, so the post-loop code was
emitting a duplicate with no matching start-step.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Comment thread adapter/vercelaisdk/uimessagestream/handler.go
Comment thread adapter/vercelaisdk/uimessagestream/handler.go
Comment thread adapter/vercelaisdk/uimessagestream/handler.go Outdated
streamToolTurn already writes finish+[DONE] on iterator error, but the
caller in StreamModelWithTools unconditionally wrote another
finish+[DONE] because len(toolRequests)==0 was true. The client would
see two finish chunks and two [DONE] sentinels.

Fix: check for finishReasonError (or empty, for context cancellation)
and return early before the caller's normal termination path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@birdayz
Copy link
Copy Markdown
Contributor Author

birdayz commented May 11, 2026

@claude review

@claude
Copy link
Copy Markdown

claude Bot commented May 11, 2026

Claude Code Review

All three review-round-2 blockers are now fixed correctly. Remaining concerns are test-coverage gaps around the new fixes and a pre-existing test that doesn't actually exercise what it claims.

Completed

  • Read changed files
  • Verified previously-flagged span lifecycle bug in handleContentPart
  • Verified previously-flagged duplicate-terminal-sequence bug on iterator-error in streamToolTurn
  • Verified previously-flagged unpaired finish-step on maxTurns exhaustion
  • Looked for new issues
  • Post review

Previously-flagged blockers — all addressed

1. handleContentPart reasoning-not-closed before tool request — fixed at adapter/vercelaisdk/uimessagestream/handler.go:343-352 (commit 73b4875). endReasoning() is now called before endTextAndAdvance() for PartToolRequest, matching handleToolTurnPart. Covered by TestStreamModel_ReasoningThenToolRequest (handler_test.go:1572-1619), which asserts reasoning-end precedes tool-input-start. Good fix and good test.

2. Duplicate finish + [DONE] on iterator error in streamToolTurn — fixed at handler.go:497-500 (commit f6bacfd):

// streamToolTurn already wrote finish+[DONE] on error.
if finishReason == finishReasonError || finishReason == "" {
    return
}

The early-return guard catches both finishReasonError (terminal chunks already written by streamToolTurn) and "" (ctx-cancel or write-error abort, where leaving the stream silently is correct). Correct.

3. Unpaired finish-step on maxTurns exhaustion — fixed at handler.go:521-522 (commit 73b4875). The extra finish-step is gone; the post-loop terminal sequence is now just finish + [DONE]. Each turn's start-step/finish-step are now properly paired via writeToolTurnEnd.


Issues

1. (test gap) No test for the iterator-error fix in tool-calling mode

adapter/vercelaisdk/uimessagestream/handler_test.go

The fix in commit f6bacfd (handler.go:497-500) addresses a real protocol-breaking bug — duplicate finish+[DONE] on any mid-stream network error during tool-calling. The fix is correct, but no test exercises it. TestStreamModel_IteratorErrorClosesSpans only covers StreamModel, not StreamModelWithTools + streamToolTurn.

A regression here would be silent (still produces output, just malformed terminal sequence) and could easily be reintroduced. Worth adding a TestStreamModelWithTools_IteratorErrorNoDuplicateTerminal that wraps an iteratorErrorModel in StreamModelWithTools with a ToolExecutor and asserts exactly one finish chunk and one [DONE] sentinel. Fix this →

2. (test gap) StreamModelWithTools and WithMaxTurns are entirely untested

grep StreamModelWithTools handler_test.go returns zero matches. The whole tool-executor path — including maxTurns exhaustion, executeTools, the assistant-message append, the per-turn start-step/finish-step pairing — has no direct coverage. TestStreamModel_ToolCall synthesizes tool responses in the event stream itself, which goes through StreamModel, not StreamModelWithTools.

Suggested coverage:

  • A happy-path test: model returns tool-calls once, executor returns a result, model returns stop on second turn. Assert chunk sequence and messages accumulation.
  • WithMaxTurns(2) with a model that always returns tool-calls: assert exactly 2 paired start-step/finish-step pairs, finish with reason "other", single [DONE]. This also locks in the protocol shape so future changes to the exhaustion path are caught.

Fix this →

3. (test bug) TestHandler_WithMaxBodyBytes doesn't validate what it claims

adapter/vercelaisdk/uimessagestream/handler_test.go:1621-1638

bigBody := strings.Repeat("x", 200)
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api/chat", strings.NewReader(bigBody))
// ...
if rec.Code != http.StatusBadRequest {
    t.Errorf("expected 400 for body exceeding custom limit, got %d", rec.Code)
}

The body is 200 raw "x" chars — invalid JSON. Decoding fails with 400 regardless of WithMaxBodyBytes(100). To verify the limit actually fires, send valid JSON that exceeds the limit (e.g. {"messages":[{"role":"user","content":" + a long string + "}]} totaling >100 bytes), or assert on the actual http.MaxBytesError path. I flagged this in the previous review and noted it as non-blocking; flagging again because the option deserves real coverage. Fix this →


Minor

4. streamToolTurn text-ID reset is a no-op

handler.go:539-541

sw.textID = fmt.Sprintf("text-%d", sw.textCounter)
sw.textStarted = false
sw.reasoningStarted = false

closeSpans() (called by writeToolTurnEnd on the prior turn) already keeps textID in sync with textCounter and sets both *Started flags to false. So these three lines are no-ops on subsequent turns and trivially true on the first turn (zero values). Either delete them or leave a comment explaining why they remain. Pre-existing from #131.

5. Comment on handler.go:497 is slightly misleading

// streamToolTurn already wrote finish+[DONE] on error.
if finishReason == finishReasonError || finishReason == "" {
    return
}

The comment is correct for finishReasonError but the "" branch covers a different case — ctx cancellation or write-failure abort — where streamToolTurn returned without writing [DONE] (intentionally, since the connection is gone). Worth either:

  • Splitting the conditions and commenting each, or
  • Broadening the comment: // terminal chunks already written or stream aborted; nothing more to write

Not a correctness issue.

6. Tests still use raw t.Errorf/t.Fatalf

CLAUDE.md asks for assert/require. Pre-existing pattern; the new tests in this PR follow it consistently. Worth a dedicated cleanup pass eventually.


What's good (round 3)

  • All three round-2 blockers fixed correctly with the right shape (close spans before terminal sequence, early-return on error in caller, drop unpaired finish-step).
  • TestStreamModel_ReasoningThenToolRequest directly proves the symmetric fix in handleContentPart — asserts reasoning-end precedes tool-input-start.
  • The finishReason == "" guard correctly handles both ctx.Err() and write-failure abort paths in streamToolTurn, preventing terminal chunk emission on broken connections.
  • Diff is tight and focused — no scope creep, easy to verify.

View job run • Branch: jb/vercel-aisdk-review-fixes

Add tests for:
- StreamModelWithTools happy path (tool call -> executor -> second turn)
- maxTurns exhaustion (paired start-step/finish-step, single finish)
- Iterator error in tool-calling mode (no duplicate finish+[DONE])

Fix TestHandler_WithMaxBodyBytes to send valid JSON exceeding the limit
instead of invalid JSON that fails decoding regardless.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 adapter/vercelaisdk/uimessagestream/handler.go:553-555 — 🔴 streamToolTurn's event switch (handler.go:566-574) has cases only for ContentPartEvent and StreamEndEventllm.StreamResetEvent silently falls through, while StreamModel correctly handles it at handler.go:450-457 (a branch this PR even tightened from endText() to endTextAndAdvance()). When a retry interceptor wrapped around the model emits StreamResetEvent mid-turn, textStarted/reasoningStarted stay true with stale IDs (fusing discarded-attempt deltas with retry deltas under the same span ID) and any toolRequests collected before the reset remain in the local slice and get executed by executeTools, double-firing tools from the discarded attempt. Fix: add case llm.StreamResetEvent: mirroring StreamModelsw.endTextAndAdvance(), sw.endReasoning(), and toolRequests = nil.

    Extended reasoning...

    What the bug is

    In streamToolTurn (handler.go:548-575), the event-dispatch switch has only two cases:

    switch e := event.(type) {
    case llm.ContentPartEvent:
        if abort := handleToolTurnPart(e, sw, &toolRequests); abort {
            return "", nil
        }
    case llm.StreamEndEvent:
        finishReason = writeToolTurnEnd(e, sw, ew, logger)
    }

    There is no case llm.StreamResetEvent: branch, so the event silently falls through with no action taken. Compare with StreamModel at handler.go:450-457 which correctly closes both spans:

    case llm.StreamResetEvent:
        if err := sw.endTextAndAdvance(); err != nil { return }
        if err := sw.endReasoning(); err != nil { return }

    This PR even tightened that branch (the diff changes endText() to endTextAndAdvance() for the StreamModel reset case), which makes the asymmetry to streamToolTurn more glaring.

    Why this path is reachable

    The StreamResetEvent contract in llm/event.go:119-126 is explicit: "Consumers should discard any accumulated content from the previous attempt and prepare to receive events from a fresh generation". The event is emitted by retry interceptors. The package doc the PR adds (handler.go:17-21) states: "interceptor plugins (retry, OTel) must be wired at the model level" — confirming that the model passed into streamToolTurn can yield StreamResetEvent whenever a retry interceptor wraps it. This is not a hypothetical path; it is documented supported usage.

    Step-by-step proof — span ID fusion

    Consider a model wrapped by a retry interceptor, with tools enabled, that yields:
    [Text("attempt1"), StreamResetEvent, Text("attempt2"), StreamEnd]

    1. ContentPartEvent{Text("attempt1")}handleToolTurnPartwriteTextDelta opens span: emits text-start id=text-0, text-delta id=text-0 delta=attempt1. State: textStarted=true, textID=text-0.
    2. StreamResetEvent → falls through the switch with no action. State unchanged: textStarted=true, textID=text-0.
    3. ContentPartEvent{Text("attempt2")}writeTextDelta sees textStarted==true and just emits text-delta id=text-0 delta=attempt2. NO new text-start.
    4. StreamEndEventwriteToolTurnEndcloseSpans()text-end id=text-0, finish-step.

    Wire output: text-start id=text-0, text-delta "attempt1", text-delta "attempt2", text-end id=text-0. The discarded attempt and the retry are fused under the same span ID, contradicting the StreamResetEvent contract. The same applies if reasoning was open: reasoningStarted stays true and the stale reasoningID continues to be used.

    Step-by-step proof — tool double-fire

    With a model that yields: [ToolRequest(call-1), StreamResetEvent, ToolRequest(call-2), StreamEnd(tool-calls)]

    1. ContentPartEvent{ToolRequest(call-1)}handleToolTurnPart PartToolRequest case → appends call-1 to local toolRequests slice → emits tool-input-start, tool-input-available for call-1.
    2. StreamResetEvent → falls through. toolRequests slice still contains call-1.
    3. ContentPartEvent{ToolRequest(call-2)} → appends call-2 to slice → emits tool chunks for call-2.
    4. StreamEndEvent{FinishReason=ToolCalls}writeToolTurnEnd returns "tool-calls". Returns to StreamModelWithTools with toolRequests=[call-1, call-2].
    5. executeTools is invoked with both call-1 (discarded) and call-2 (retry) — executing the tool for the discarded attempt and feeding its result back to the model in the next turn.

    For non-idempotent tools (database writes, payment APIs, email sending) this is a correctness bug, not just a wire-protocol artifact.

    Impact

    1. Span ID protocol violation: span IDs persist across the reset boundary, fusing discarded and retry content under the same text-N/reasoning-N id on the wire. UI clients keyed off span IDs will display the discarded attempt's text concatenated with the retry attempt, defeating the whole purpose of retry semantics.
    2. Tool double-execution: tool requests from a discarded attempt remain in the local slice and are executed by executeTools after the turn ends, plus their tool-output-available chunks are sent to the client, and their results are appended to messages for the next turn — effectively poisoning the conversation with phantom tool calls from a stream that was supposed to be discarded.
    3. Asymmetry with StreamModel: this is exactly the class of bug the PR set out to fix — handleContentPart vs handleToolTurnPart, the closeSpans() symmetry between text and reasoning, etc. The existing test TestStreamModel_StreamResetEvent (and TestStreamModel_StreamResetEventWithReasoning) cover the non-tools path; no equivalent exists for the tools path.

    How to fix

    Add a case llm.StreamResetEvent: branch to streamToolTurn's switch mirroring StreamModel, and also reset the local toolRequests slice:

    case llm.StreamResetEvent:
        if err := sw.endTextAndAdvance(); err != nil { return "", nil }
        if err := sw.endReasoning(); err != nil { return "", nil }
        toolRequests = nil

    A companion test TestStreamModelWithTools_StreamResetEvent mirroring the existing TestStreamModel_StreamResetEvent would also be valuable.

Comment thread adapter/vercelaisdk/uimessagestream/handler.go
Comment thread adapter/vercelaisdk/uimessagestream/handler_test.go
@blacksmith-sh

This comment has been minimized.

Comment thread adapter/vercelaisdk/uimessagestream/handler.go Outdated
messages = append(messages, llm.Message{Role: llm.RoleAssistant, Content: assistantParts})

if err := executeTools(ctx, toolRequests, &messages, ew, executor); err != nil {
if err := executeTools(ctx, toolRequests, &messages, ew, logger, executor); err != nil {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 🟡 Pre-existing bug (from #131, not introduced by this PR): In StreamModelWithTools, text and reasoning emitted alongside a tool call in the same model turn are streamed to the client but not preserved in the conversation history passed to the next model turn. The assistant message at handler.go:509-514 is built solely from toolRequests — any preamble like "Let me check the weather..." or reasoning the model just generated is invisible to it on the next call. Fix: accumulate text/reasoning parts in handleToolTurnPart and include them in assistantParts alongside the tool requests. Since this PR adds a Known limitations doc block covering inbound-history reconstruction, it's a natural moment to either fix this symmetric outbound gap or document it.

Extended reasoning...

What the bug is

In handleToolTurnPart (handler.go:580-617), the three content-part cases behave asymmetrically with respect to next-turn history:

case llm.PartText:
    if err := sw.writeTextDelta(e.Part.Text); err != nil { ... }   // streamed only
case llm.PartReasoning:
    if err := sw.writeReasoningDelta(e.Part.ReasoningTrace); err != nil { ... }  // streamed only
case llm.PartToolRequest:
    ...
    *toolRequests = append(*toolRequests, tr)   // ← accumulated
    if err := sw.writeToolRequest(tr); err != nil { ... }

Only PartToolRequest accumulates into the slice. Then in StreamModelWithTools (handler.go:509-514), the assistant message is constructed exclusively from tool requests:

assistantParts := make([]*llm.Part, 0, len(toolRequests))
for _, tr := range toolRequests {
    assistantParts = append(assistantParts, llm.NewToolRequestPart(tr))
}
messages = append(messages, llm.Message{Role: llm.RoleAssistant, Content: assistantParts})

So the assistant turn the model sees on iteration N+1 contains only its tool_use blocks — its own preceding text and reasoning are gone.

Specific code path that triggers it

A real model commonly emits something like [Text("Let me look that up..."), ToolRequest(getWeather, {...})] in a single turn. After streamToolTurn returns, executeTools appends a RoleUser message containing only tool responses. The next call to model.GenerateEvents sees [user("weather?"), assistant(tool_use only), user(tool_responses)] — no preamble text, no reasoning trace.

Why existing code doesn't prevent it

The new TestStreamModelWithTools_HappyPath added in this PR uses a model that emits Text("calling tool") before the tool call, but only inspects the SSE chunk sequence — it never asserts on the inter-turn messages slice that gets passed back to GenerateEvents. So the regression silently passes. The conformance suite likewise focuses on wire chunks, not on history accumulation across turns.

Step-by-step proof

Consider a 2-turn agentic interaction with a model that emits text alongside its tool call. Initial req.Messages = [user("weather?")].

  1. Turn 1 begins. streamToolTurn called with messages = [user("weather?")].
  2. Model yields ContentPartEvent{Text("Let me check.")}writeTextDelta emits text-start, text-delta, text-end to the client. toolRequests remains empty.
  3. Model yields ContentPartEvent{ToolRequest{ID:"call-1", Name:"getWeather", Args:{...}}} → tool span chunks emitted, toolRequests = [tr1].
  4. Model yields StreamEndEvent{FinishReason: tool-calls}writeToolTurnEnd emits finish-step.
  5. Control returns to StreamModelWithTools. assistantParts = [ToolRequestPart(tr1)]text is dropped.
  6. messages = [user("weather?"), assistant(tool_use:tr1)].
  7. executeTools appends user(tool_response:call-1="72F sunny").
  8. Turn 2 begins. Model is called with messages = [user("weather?"), assistant(tool_use:tr1), user(tool_response)]. The assistant's prior "Let me check." preamble is invisible on this call. The model may now repeat its preamble ("Let me check the weather..."), lose continuity with the reasoning it just produced, or behave inconsistently with its prior statements.

Impact

  • Anthropic prompt-cache continuity breaks. Anthropic retains assistant text alongside tool_use in their content array; reconstructing the assistant turn without the preceding text invalidates cache prefix hits and increases token cost on subsequent turns of long agentic chains.
  • Strict content validators can reject. Some provider SDKs reject assistant messages that contain tool_use blocks with no associated text content when text was originally produced, depending on validation strictness.
  • Multi-turn agentic accuracy degrades. The new WithMaxTurns option (added in this PR, default 10) increases the blast radius — longer agentic chains amplify the divergence between what the model said and what it sees it said.
  • Reasoning trace continuity is also lost. For reasoning-capable models (e.g. Claude with thinking, OpenAI o-series), the inter-tool reasoning the model emits is silently dropped before the next turn, breaking the model's ability to build on its own chain of thought.

How to fix

Accumulate text and reasoning parts in handleToolTurnPart alongside the tool requests, and include them in the assistant Content. Sketch:

// In streamToolTurn:
var assistantContent []*llm.Part
// pass &assistantContent into handleToolTurnPart

// In handleToolTurnPart:
case llm.PartText:
    if err := sw.writeTextDelta(e.Part.Text); err != nil { return true }
    *assistantContent = append(*assistantContent, e.Part)
case llm.PartReasoning:
    if err := sw.writeReasoningDelta(e.Part.ReasoningTrace); err != nil { return true }
    *assistantContent = append(*assistantContent, e.Part)
case llm.PartToolRequest:
    ...
    *toolRequests = append(*toolRequests, tr)
    *assistantContent = append(*assistantContent, e.Part)
    ...

// In StreamModelWithTools, replace the assistantParts loop with:
messages = append(messages, llm.Message{Role: llm.RoleAssistant, Content: assistantContent})

Note: text deltas from the same span should ideally be concatenated into a single PartText per logical span (or the llm.Message format should permit multiple text parts, which it does).

Why this PR is the natural moment

This PR substantially modifies StreamModelWithTools (new maxTurns parameter, signature change, early-return guards on error, post-loop finish-step drop) and adds a package-level Known limitations block that explicitly enumerates the inbound tool-history gap ("only text parts are forwarded to the model"). The symmetric outbound gap — what we send back to the model after a tool turn — is not mentioned. Either fix it here or extend the doc block to cover it.

- Remove no-op textID/reasoningStarted reset in streamToolTurn;
  closeSpans already keeps these in sync between turns.
- Clarify the early-return comment in StreamModelWithTools to
  distinguish the error path from the ctx-cancel/write-failure path.
- Convert all raw t.Fatalf/t.Errorf assertions to testify
  assert/require, matching the project convention in CLAUDE.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@birdayz
Copy link
Copy Markdown
Contributor Author

birdayz commented May 11, 2026

@claude review

@claude
Copy link
Copy Markdown

claude Bot commented May 11, 2026

Claude Code Review

Latest commits (e8429f9, f5b2290) wrap up testify migration and test coverage from prior rounds. One critical regression remains — the new early-return guard from f6bacfd over-triggers and breaks the standard StreamEndEvent.Error terminal path (no finish / [DONE] emitted). The previously-flagged StreamResetEvent gap in streamToolTurn is also still unfixed.

Completed

  • Read changed files
  • Verify previously-flagged blockers
  • Look for new issues introduced by latest commits
  • Post review

Issues

1. 🔴 (regression — blocking) Early-return guard breaks StreamEndEvent.Error terminal path

adapter/vercelaisdk/uimessagestream/handler.go:497-501

The guard added in commit f6bacfd to fix the iterator-error duplicate-terminal bug over-triggers. Two distinct code paths in streamToolTurn produce finishReason == "error", and they emit different chunk sequences before returning:

  • Iterator-error path (handler.go:551-565): writes the full terminal sequence inline (error, finish-step, finish, [DONE]), then returns "error". Guard correctly prevents duplication.
  • StreamEndEvent.Error path (handler.go:619-637, writeToolTurnEnd): writes only error + finish-step, returns "error". The guard then prevents the caller from emitting finish + [DONE] — stream ends mid-protocol.

StreamEndEvent.Error is the documented standard terminal-error pattern (llm/api.go:135-142) used by every provider in this repo (Anthropic at providers/anthropic/model.go:297, OpenAI at providers/openai/model.go:185, Google at providers/google/model.go:225, Bedrock at providers/bedrock/model.go:234). Any provider response-mapping failure or terminal stream error during a tool-calling turn produces a hanging SSE stream — no finish chunk, no [DONE], client blocks until TCP close.

Verified the new TestStreamModelWithTools_IteratorErrorNoDuplicateTerminal only exercises the iterator-error path (uses iteratorErrorModel which yields (nil, err)), so this regression is uncovered by tests.

Fix: either make the iterator-error branch stop writing terminal chunks (and let the caller emit them once — mirroring StreamModel's break pattern), or make writeToolTurnEnd symmetrically write finish + [DONE] so both paths emit the same trailer. Smaller diff is option 1: remove finish/[DONE] writes from the iterator-error branch in streamToolTurn, remove the guard, and the existing fall-through at handler.go:503-507 handles both cases uniformly. Fix this →

2. 🔴 (still unfixed from round 3) StreamResetEvent not handled in streamToolTurn

adapter/vercelaisdk/uimessagestream/handler.go:568-576

The switch still has only ContentPartEvent and StreamEndEvent cases — llm.StreamResetEvent silently falls through. The matching code in StreamModel at handler.go:450-457 (which this PR tightened from endText() to endTextAndAdvance()) is correct; the asymmetry remains.

Impact when a retry interceptor wraps the model with tools enabled (which the package doc explicitly states is the supported wiring):

  1. Span ID fusion: textStarted/reasoningStarted stay true with stale IDs; discarded-attempt and retry deltas fuse under the same text-N / reasoning-N, contradicting the StreamResetEvent contract.
  2. Tool double-fire: toolRequests collected before the reset stay in the local slice → executeTools runs them after writeToolTurnEnd returns. For non-idempotent tools (DB writes, payments, etc.), this is a correctness bug, not just a wire artifact.

Fix is a one-case mirror plus a slice reset:

case llm.StreamResetEvent:
    if err := sw.endTextAndAdvance(); err != nil { return "", nil }
    if err := sw.endReasoning(); err != nil { return "", nil }
    toolRequests = nil

A companion TestStreamModelWithTools_StreamResetEvent mirroring the existing TestStreamModel_StreamResetEvent would also lock this in. Fix this →


Minor

3. 🟡 PartToolResponse in handleContentPart doesn't close open spans

adapter/vercelaisdk/uimessagestream/handler.go:353-354

case llm.PartToolResponse:
    return sw.writeToolResponse(part.ToolResponse)

The sibling PartToolRequest case (handler.go:343-352, fixed by this PR) closes both spans before writing the tool chunk; PartToolResponse does not. A Reasoning → ToolResponse or Text → ToolResponse sequence via ContentPartEvent (the StreamModel non-executor path) would emit tool-output-available with the span still open, and the matching *-end would arrive out of order. Real production providers don't typically interleave PartToolResponse mid-generation, so reachability is narrow — but symmetry with the sibling case is trivial:

case llm.PartToolResponse:
    if err := sw.endReasoning(); err != nil { return err }
    if err := sw.endTextAndAdvance(); err != nil { return err }
    return sw.writeToolResponse(part.ToolResponse)

4. 🟡 Outbound text/reasoning lost across agentic turns (pre-existing)

adapter/vercelaisdk/uimessagestream/handler.go:510-515

assistantParts := make([]*llm.Part, 0, len(toolRequests))
for _, tr := range toolRequests {
    assistantParts = append(assistantParts, llm.NewToolRequestPart(tr))
}
messages = append(messages, llm.Message{Role: llm.RoleAssistant, Content: assistantParts})

Any text or reasoning the model emits alongside a tool call (e.g. "Let me check the weather...") is streamed to the client but dropped from the assistant message passed back on the next turn. Anthropic cache-prefix hits break, multi-turn agentic continuity degrades, and the impact grows with the new WithMaxTurns (default 10) — longer chains amplify divergence.

This is pre-existing from #131 and the PR description doesn't claim to fix it. But since this PR adds a Known limitations doc block covering the inbound tool-history gap, the symmetric outbound gap is the natural moment to either fix or document. If fixing is out of scope, extending the doc block to cover it would be valuable.

5. Test helpers still use raw t.Fatalf (3 call sites)

adapter/vercelaisdk/uimessagestream/handler_test.go:56, 68, 91

The migration to testify in commit e8429f9 was thorough — only three remain, all inside helper functions (parseSSE, chunkStr). Helpers calling assert.FailNow / require against a non-t *testing.T shape is more awkward, so leaving these is reasonable. Flagging for visibility.


What's good (round 4)

  • Round 2 + 3 blockers all addressed: handleContentPart reasoning-not-closed, post-maxTurns unpaired finish-step, and the iterator-error duplicate-terminal in tool-calling — all gone.
  • Round 3 test coverage gaps closed: TestStreamModelWithTools_HappyPath, TestStreamModelWithTools_MaxTurnsExhaustion (asserts paired step boundaries and single finish), TestStreamModelWithTools_IteratorErrorNoDuplicateTerminal (asserts exactly one finish + one [DONE]).
  • TestHandler_WithMaxBodyBytes now sends valid JSON exceeding the limit, so the test would actually fail if WithMaxBodyBytes were ignored.
  • No-op text-ID reset removed from streamToolTurncloseSpans already keeps IDs in sync.
  • Comment on the early-return guard clarified to distinguish error vs. ctx-cancel/write-failure (though the error branch itself is the new bug above — see issue initial commit #1).
  • testify migration widespread in the new tests; pre-existing helpers are the only holdouts.

View job run • Branch: jb/vercel-aisdk-review-fixes

…mode

The early-return guard for iterator errors in StreamModelWithTools
over-triggered: it also caught StreamEndEvent{Error: ...} from
writeToolTurnEnd, which only writes error+finish-step (not
finish+[DONE]). Result: stream hangs with no finish chunk.

Fix: streamToolTurn's iterator-error branch no longer writes
finish+[DONE] itself. Both error paths (iterator error and
StreamEndEvent.Error) now return a finishReason, and the caller
writes finish+[DONE] uniformly. The guard only catches "" (ctx
cancel / write failure), not "error".

Also:
- Add StreamResetEvent handling to streamToolTurn, mirroring
  StreamModel. Closes open spans and clears collected tool requests
  to prevent double-firing tools from a discarded attempt.
- Close open spans before PartToolResponse in handleContentPart,
  symmetric with PartToolRequest.

Every fix has a corresponding test:
- TestStreamModelWithTools_StreamEndEventWithError
- TestStreamModelWithTools_StreamResetEvent (asserts executor not
  called for discarded tool requests)
- TestStreamModel_ToolResponseClosesSpans

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings (outside current diff — PR may have been updated during review):

  • 🟣 adapter/vercelaisdk/uimessagestream/handler.go:542 — Pre-existing from #131, but flagging since this PR substantially modifies StreamModelWithTools: streamToolTurn constructs its per-turn iterReq (handler.go:542-546) by copying only Messages, Tools, and ToolChoice from the caller's req — silently dropping Options, ResponseFormat, and Metadata on every iteration. Direct callers of the exported StreamModelWithTools API who set Options (temperature/max_tokens/top_p/reasoning level) will get their tuned config on turn 1 but provider defaults on turns 2+; Metadata loss breaks OTel/tracing correlation mid-chain. One-line fix: iterReq := *req; iterReq.Messages = messages.

    Extended reasoning...

    What goes wrong

    streamToolTurn at adapter/vercelaisdk/uimessagestream/handler.go:542-546 constructs the per-turn request as:

    iterReq := &llm.Request{
        Messages:   messages,
        Tools:      req.Tools,
        ToolChoice: req.ToolChoice,
    }

    But llm.Request (llm/request.go:22-47) has six fields: Messages, Tools, ToolChoice, ResponseFormat, Options, Metadata. The latter three are silently dropped on every iteration of the agentic tool-calling loop.

    Impact

    • Options is the most consequential drop. Documented at llm/request.go:39-42 as "provider-specific configuration: temperature, max_tokens, top_p, etc." Production callers nearly always set this. Multi-turn tool-calling silently reverts to provider defaults after turn 1 — any temperature/max_tokens/reasoning-level tuning is lost.
    • Metadata loss breaks OTel correlation. Per llm/request.go:44-46, Metadata "flows through for tracing, logging, and debugging." Dropping it on turn 2+ breaks observability tags mid-conversation, making it impossible to correlate spans across an agentic chain.
    • ResponseFormat loss means a JSON-schema set on the first turn won't constrain the final-answer turn's output.

    Contrast with the single-turn path StreamModel (handler.go:415-417), which passes req directly to GenerateEvents and preserves everything.

    Step-by-step proof

    Consider a direct caller invoking StreamModelWithTools with:

    req := &llm.Request{
        Messages: msgs,
        Tools:    tools,
        Options:  openai.Options{Temperature: 0.2, MaxTokens: 4096},
        Metadata: map[string]string{"trace_id": "abc-123"},
    }
    StreamModelWithTools(ctx, model, req, ew, logger, executor, 0)
    1. Turn 1: streamToolTurn builds iterReq = {Messages: msgs, Tools, ToolChoice}Options and Metadata are nil. The provider sees default temperature, default max_tokens, no trace_id. (Even on turn 1 the caller's Options/Metadata are dropped.)
    2. Model returns a tool call. executeTools appends the response.
    3. Turn 2: same thing — iterReq again has nil Options/Metadata.
    4. Every subsequent turn: same drop.

    The caller's tuned config is honored zero times, not even on the first turn.

    Scope

    The HTTP Handler path is invisible to this bug because chatRequest (handler.go:111-116) doesn't decode these fields and the constructed req at handler.go:181-184 only sets Messages and Tools. The bug only manifests for direct callers of the exported StreamModelWithTools from custom handlers — but that's exactly the audience this PR adds TestStreamModelWithTools_* coverage for, establishing it as a supported public API surface.

    Why this PR is the natural moment

    Pre-existing from #131, but the PR substantially modifies StreamModelWithTools (adds maxTurns parameter, breaks the signature, refactors error paths, adds early-return guards) and introduces a package-level Known limitations block that already documents the inbound-history gap. The symmetric outbound-config gap deserves either a fix or a doc note alongside it. The signature is already breaking, so propagating the missing fields is essentially free.

    How to fix

    One-line shallow clone preserves everything and is robust to future llm.Request field additions:

    iterReq := *req
    iterReq.Messages = messages

    (Or copy the three missing fields explicitly. The shallow-clone form is preferred — any future Request field is propagated automatically.)

  • 🔴 adapter/vercelaisdk/uimessagestream/handler.go:555-558 — streamToolTurn's event switch (handler.go:568-576) only handles ContentPartEvent and StreamEndEvent, silently dropping ErrorEvent and StreamResetEvent — both of which StreamModel handles correctly at handler.go:443-457. This is asymmetric and the StreamResetEvent drop is the severe case: when a retry interceptor is wrapped around the model (a configuration the new Known Limitations doc block explicitly endorses), the reset is silently ignored, leaving text/reasoning spans open across the boundary AND leaving the local toolRequests slice populated with stale requests from the failed attempt. executeTools then runs both the stale and fresh tool calls, and both land in the next-turn assistant message. Fix is to mirror StreamModel's two missing arms in streamToolTurn's switch.

    Extended reasoning...

    What the bug is

    streamToolTurn and StreamModel consume the same iter.Seq2[llm.Event, error] channel but their type-switches are not symmetric:

    • StreamModel (handler.go:437-461) handles ContentPartEvent, ErrorEvent (logs + emits sanitized error chunk), StreamResetEvent (calls endTextAndAdvance + endReasoning to close spans across the retry boundary), and StreamEndEvent.
    • streamToolTurn (handler.go:568-576) handles only ContentPartEvent and StreamEndEvent. The other two cases fall through silently.

    The PR is the natural moment to flag this because (a) the diff touches the StreamResetEvent arm in StreamModel directly (line 451: endText()endTextAndAdvance()), (b) the diff adds sw.closeSpans() immediately above the deficient streamToolTurn switch, and (c) the new package doc block explicitly legitimizes retry-interceptor-at-model-level as the supported way to use retries with this handler — which is exactly the configuration that exposes the StreamResetEvent drop.

    Why the StreamResetEvent drop is the severe case

    plugins/retry/retry.go:170-175 confirms StreamResetEvent is emitted by the retry interceptor on every retry attempt:

    if !yield(llm.StreamResetEvent{Attempt: attempt, Reason: lastErr.Error()}, nil) {
        return
    }

    llm/event.go documents that consumers must "discard any accumulated content from the previous attempt and prepare to receive events from a fresh generation." streamToolTurn keeps two pieces of accumulated state across iterations of its for event, err := range model.GenerateEvents(...) loop:

    1. The streamWriter span state (textStarted, reasoningStarted, textID, reasoningID).
    2. The local toolRequests []*llm.ToolRequest slice.

    Dropping StreamResetEvent leaves both of those carrying failed-attempt state into the retry attempt.

    Step-by-step proof (StreamModelWithTools with retry-wrapped model + executor)

    Attempt 1 starts, model emits some content + a tool request, then the underlying provider blips and the retry interceptor kicks in.

    1. ContentPartEvent{Text("partial ")}handleToolTurnPartwriteTextDelta → emits text-start (id=text-0), text-delta. State: textStarted=true, textID="text-0".
    2. ContentPartEvent{ToolRequest{ID:"call-stale", Name:"getWeather", ...}}handleToolTurnPart PartToolRequest arm → closes text (endTextAndAdvancetext-end, textCounter++, textID="text-1", textStarted=false), appends to toolRequests (now [call-stale]), emits tool-input-start/tool-input-available for call-stale.
    3. StreamResetEvent{Attempt:1, Reason:"..."}falls through the switch silently. State unchanged: toolRequests=[call-stale]. (No spans currently open, but they would be on a different interleaving — e.g. if step 2 hadn't happened yet, textStarted would still be true and step 4's deltas would land on the failed attempt's span.)
    4. Retry yields ContentPartEvent{Text("fresh answer")} → emits text-start (id=text-1), text-delta.
    5. Retry yields ContentPartEvent{ToolRequest{ID:"call-fresh", ...}} → appends → toolRequests=[call-stale, call-fresh]. Emits tool spans for call-fresh.
    6. StreamEndEvent{FinishReason: tool-calls}writeToolTurnEnd closes spans, emits finish-step, returns "tool-calls".

    Damage on return to StreamModelWithTools:

    • handler.go:509-514 builds an assistant message from both tool requests: assistantParts = [ToolRequestPart(call-stale), ToolRequestPart(call-fresh)]. The next-turn messages slice now carries a phantom tool_use for call-stale that the model never actually emitted on the successful attempt.
    • handler.go:517 calls executeTools which runs both call-stale and call-fresh. The stale tool may perform side effects (write to a DB, send an email) or simply waste a tool budget; the corresponding tool-output-available chunks go on the wire for both, confusing the UI; tool_response parts for both go into the next-turn user message.

    On the next model turn, the conversation history contains a fabricated assistant message + tool responses that have no relationship to the actual exchange the model produced — model behavior on that turn is unpredictable, and Anthropic in particular will reject the request if the tool_use ids don't match what it just emitted.

    Step-by-step proof (ErrorEvent drop)

    A provider yields ErrorEvent{Message:"rate-limit warning"} mid-tool-turn:

    • StreamModel (handler.go:443-448): logs and emits {"type":"error","errorText":"An error occurred"} so the UI can render a soft warning. Stream continues.
    • streamToolTurn: event silently disappears. The protocol surface is asymmetric depending only on whether a ToolExecutor is configured.

    Why existing tests don't catch it

    TestStreamModel_StreamResetEvent and TestStreamModel_StreamResetEventWithReasoning exercise only the non-tool path (StreamModel). The new tool-mode tests (TestStreamModelWithTools_HappyPath, TestStreamModelWithTools_MaxTurnsExhaustion, TestStreamModelWithTools_IteratorErrorNoDuplicateTerminal) never feed a StreamResetEvent or ErrorEvent into the tool-calling iterator. There is no TestStreamModelWithTools_*Reset* or *ErrorEvent* test.

    Fix

    Mirror StreamModel's switch arms in streamToolTurn. The toolRequests = nil step is the subtle part — without it, stale tool calls from a failed attempt persist into the next-turn assistant message:

    case llm.ErrorEvent:
        logger.Warn("recoverable LLM error", "message", e.Message)
        if err := ew.WriteChunk(Chunk{"type": "error", "errorText": "An error occurred"}); err != nil {
            return "", nil
        }
    
    case llm.StreamResetEvent:
        if err := sw.endTextAndAdvance(); err != nil {
            return "", nil
        }
        if err := sw.endReasoning(); err != nil {
            return "", nil
        }
        toolRequests = nil

Comment thread adapter/vercelaisdk/uimessagestream/handler.go
Comment thread adapter/vercelaisdk/uimessagestream/handler.go
Comment thread adapter/vercelaisdk/uimessagestream/handler.go
…quest fields

Three issues from review round 5:

StreamResetEvent in streamToolTurn now emits tool-output-error for each
accumulated tool request before clearing the slice. Without this, tool
inputs already written to the wire had no matching output, leaving
useChat clients with perpetually-pending tool-call cards.

Add ErrorEvent case to streamToolTurn, mirroring StreamModel. Without
this, recoverable provider warnings (emitted by OpenAI, Google) were
silently dropped in tool-calling mode.

Fix iterReq construction in streamToolTurn to copy the full request
(Options, ResponseFormat, Metadata) instead of cherry-picking three
fields. Previously, provider-specific options like MaxTokens and
Temperature were silently dropped on turns 2..N.

Every fix has a test:
- TestStreamModelWithTools_StreamResetEvent (updated: expects
  tool-output-error for discarded request)
- TestStreamModelWithTools_ErrorEventNonTerminal
- TestStreamModelWithTools_RequestFieldsForwarded

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Comment thread adapter/vercelaisdk/uimessagestream/handler.go
When an iterator error occurs after tool-input-start/tool-input-available
have been written to the wire, emit tool-output-error for each pending
tool request before the error/finish-step sequence. Without this, the
useChat client sees a perpetually-pending tool-call card.

Mirrors the StreamResetEvent fix from the previous commit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Comment thread adapter/vercelaisdk/uimessagestream/handler.go

case llm.StreamResetEvent:
if err := sw.endText(); err != nil {
if err := sw.endTextAndAdvance(); err != nil {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 🟣 Pre-existing (from #131): StreamModel (the non-executor path) emits tool-input-start/tool-input-available via handleContentPart's PartToolRequest case (handler.go:343-352) but tracks no pending tool requests — so on iterator-error (handler.go:429-442), StreamResetEvent (handler.go:458-465), or StreamEndEvent.Error via writeStreamEnd (handler.go:388-407), any previously-emitted tool-input-available is left unpaired on the wire, violating the protocol invariant just enforced in streamToolTurn. Reachable through the public API as Handler(model, WithTools(tools, nil)) (WithTools accepts nil executor; ServeHTTP sets req.Tools regardless; StreamModelWithTools delegates to StreamModel when executor==nil at handler.go:481-484) or via direct calls to the exported StreamModel with req.Tools populated. Fix template is identical to what this PR applied in streamToolTurn: track pending tool-call IDs and emit tool-output-error for each before the terminal chunks; alternatively reject WithTools(tools, nil) at config time.

Extended reasoning...

What the bug is

StreamModel (the non-executor path) emits tool-input-start and tool-input-available chunks for any PartToolRequest content event via handleContentPart (handler.go:343-352) → writeToolRequest (handler.go:292-313). But unlike streamToolTurn — which maintains a local toolRequests []*llm.ToolRequest slice — StreamModel does not track these pending tool calls anywhere.

When any of three terminal events fires after such chunks have been written:

  1. Iterator-error at handler.go:429-442: sw.closeSpans() only closes text/reasoning spans; writes error/finish-step/finish. No tool-output-error for orphaned tool calls.
  2. StreamResetEvent at handler.go:458-465: endTextAndAdvance + endReasoning only. No tool-output-error.
  3. StreamEndEvent.Error via writeStreamEnd at handler.go:388-407: error + closeSpans + finish-step + finish. No tool-output-error.

All three leave the previously-emitted tool-input-available unpaired on the wire — the exact pairing invariant the PR just enforced in streamToolTurn at handler.go:555-568 (iterator-error) and handler.go:588-597 (StreamResetEvent).

Why this PR is the natural moment

This PR's diff explicitly:

  • Added sw.closeSpans() to StreamModel's iterator-error branch at handler.go:436.
  • Changed endText() to endTextAndAdvance() in StreamModel's StreamResetEvent case at handler.go:459.
  • Applied the symmetric tool-output-error pairing fix in streamToolTurn for both iter-error and StreamResetEvent.

So the same hunks were touched, the same template applied to the executor sibling — but StreamModel's lack of a pending-IDs slice meant the symmetric fix needs a small additional structural change (track IDs) rather than just looping over an existing slice.

Step-by-step proof (StreamResetEvent path)

Provider yields [ContentPartEvent{ToolRequest{ID: "call-1"}}, StreamResetEvent, ContentPartEvent{Text("hi")}, StreamEndEvent{Stop}] to StreamModel:

  1. start, start-step written.
  2. PartToolRequesthandleContentPartendReasoning (no-op), endTextAndAdvance (no-op), writeToolRequest → emits tool-input-start, tool-input-available for call-1.
  3. StreamResetEvent → handler.go:458-465 → endTextAndAdvance (no-op), endReasoning (no-op). No tool-output-error for call-1.
  4. PartText("hi")text-start, text-delta.
  5. StreamEndEvent{Stop}writeStreamEndtext-end, finish-step, finish.
  6. [DONE].

Wire output:

start, start-step,
tool-input-start, tool-input-available,   <-- call-1 (orphaned)
text-start, text-delta, text-end,
finish-step, finish, [DONE]

call-1 enters pending on tool-input-available and never receives a paired tool-output-*. Same orphan pattern for iterator-error and StreamEndEvent.Error.

Reachability

Two routes through the public API:

  1. Handler(model, WithTools(tools, nil)): WithTools (handler.go:87-92) accepts a ToolExecutor function with no nil check. ServeHTTP always sets req.Tools = h.cfg.tools (handler.go:170). StreamModelWithTools sees executor == nil and delegates to StreamModel at handler.go:481-484, passing req with Tools populated. The model receives tools and may emit PartToolRequest. This configuration corresponds to client-side tool execution.
  2. Direct calls to the exported StreamModel with req.Tools set by external callers (StreamModel is exported at handler.go:411).

Addressing the refutation

The refutation argues the no-executor path operates under a different contract: tool-input-available is the terminal tool chunk and the client handles resolution in a subsequent request. Two points in response:

  • The package doc explicitly states "Inbound tool call history from multi-turn conversations is not yet reconstructed; only text parts are forwarded to the model" (handler.go:18). So full client-side execution isn't really supported in either direction. The tool-input-available chunks are server-side state the client must somehow drop on its own when the stream errors.
  • Even granting the client-side interpretation, on the error terminal paths (iterator error, StreamEndEvent.Error, StreamResetEvent for a discarded attempt) the protocol invariant remains — those tool calls did not complete and won't complete, so tool-output-error is the correct signal. The PR's own streamToolTurn fix codifies this for the same three error paths.

Reachability via WithTools(tools, nil) is genuinely undocumented, granting that — but StreamModel is exported and the nil-executor delegation is intentional code (not just a defensive check), and the symmetric fix template is right there in the PR's diff.

Severity rationale

pre_existing. The orphan was there before this PR, the PR didn't introduce the bug, and the typical Handler(model, WithTools(tools, executor)) configuration routes through streamToolTurn (now fixed). But worth flagging here because the symmetric fix pattern is what the PR just applied next door.

How to fix

Option 1 (mirror streamToolTurn): track pending tool IDs in StreamModel and emit tool-output-error for each on the three terminal paths before the terminal chunks. Requires propagating a small slice down through handleContentPart or a thin wrapper.

Option 2 (smaller diff): reject WithTools(tools, nil) at config time, or document that tools-without-executor is unsupported. This closes route #1 but not direct StreamModel calls.

@birdayz
Copy link
Copy Markdown
Contributor Author

birdayz commented May 12, 2026

@claude review

@claude
Copy link
Copy Markdown

claude Bot commented May 12, 2026

Claude Code Review

Latest commits address most of the round 5 findings (orphan tool-input pairing on iterator-error and StreamResetEvent, the missing ErrorEvent case, and the dropped Options/ResponseFormat/Metadata request fields are all in). One symmetric path is still missing the same fix: StreamEndEvent.Error with previously-emitted tool requests still produces an orphaned tool-input-available on the wire.

Completed

  • Read changed files
  • Verify previously-flagged round 5 issues
  • Look for new issues
  • Post review

Issues

1. 🔴 (carried forward from round 5, partially fixed) StreamEndEvent.Error path still leaves orphaned tool-input-available chunks

adapter/vercelaisdk/uimessagestream/handler.go:608-609handler.go:653-672

There are three symmetric error paths inside streamToolTurn that can fire after tool-input-start/tool-input-available chunks have already shipped. This PR added the pairing-cleanup loop (emit tool-output-error for each pending tool request) to two of them:

  • Iterator-error branch (lines 564-569) — added by b93c88f. ✓
  • StreamResetEvent branch (lines 599-604) — added by 06f0a7a. ✓
  • StreamEndEvent branch with e.Error != nil (line 608-609 → writeToolTurnEnd at 653-672) — still missing.

writeToolTurnEnd emits error + closeSpans() + finish-step when e.Error != nil, but never iterates toolRequests. closeSpans() only finalizes text/reasoning spans (lines 371-385), not tool spans. So any PartToolRequest that streamed its input before a StreamEndEvent{Error: err} is left orphaned.

This is the documented standard terminal-error pattern (llm/api.go:135-142) used by every in-repo provider — providers/anthropic/model.go:297, providers/openai/model.go:185, providers/google/model.go:225, providers/bedrock/model.go:234. Any provider yielding a streamed tool input followed by a mapping/HTTP/rate-limit/content-filter error triggers this — for example, mid-tool-call message-too-long errors from Anthropic that arrive as terminal StreamEndEvent.Error.

Step-by-step proof. Provider yields [ContentPartEvent{ToolRequest{ID:"call-1"}}, StreamEndEvent{Error: err}] to StreamModelWithTools with an executor configured:

  1. StreamModelWithTools writes start, enters loop, calls streamToolTurn.
  2. streamToolTurn writes start-step.
  3. ContentPartEvent{ToolRequest{ID:"call-1"}}handleToolTurnPart PartToolRequest case → appends call-1 to toolRequests (line 639) → sw.writeToolRequest(tr) emits tool-input-start + tool-input-available. State: toolRequests=[call-1].
  4. StreamEndEvent{Error: err}writeToolTurnEnd (line 609) → emits error, closeSpans() (no-op for tool spans), finish-step, returns "error". No tool-output-* for call-1.
  5. streamToolTurn returns ("error", [call-1]).
  6. StreamModelWithTools at line 510-515: finishReason=="error" is not "" so the abort guard doesn't fire; finishReason != "tool-calls" so it writes finish + [DONE] and returns.

Wire output:

start, start-step,
tool-input-start, tool-input-available,   ← emitted for call-1
error, finish-step,                       ← from writeToolTurnEnd
finish (reason=error), [DONE]             ← from caller

The tool-input-available for call-1 has no matching tool-output-available or tool-output-error — Vercel useChat tracks tool-call state by toolCallId, so call-1 enters the pending state and never leaves it.

Why no existing test catches this. TestStreamModelWithTools_StreamEndEventWithError (handler_test.go:1590-1633) feeds only Text("partial") → StreamEndEvent{Error} — no PartToolRequest. TestStreamModelWithTools_IteratorErrorPairsToolChunks covers iterator-error + tool-request. TestStreamModelWithTools_StreamResetEvent covers reset + tool-request. The combination PartToolRequest + StreamEndEvent.Error is the unexercised gap.

Fix. Smallest diff in the StreamEndEvent case at line 608-609:

case llm.StreamEndEvent:
    finishReason = writeToolTurnEnd(e, sw, ew, logger)
    if e.Error != nil {
        for _, tr := range toolRequests {
            _ = ew.WriteChunk(Chunk{
                "type": "tool-output-error", "toolCallId": tr.ID,
                "errorText": "stream error; tool call discarded",
            })
        }
        toolRequests = nil
    }

For protocol-ordering parity with the iterator-error branch (which emits tool-output-error before error and finish-step), it'd be cleaner to pass toolRequests into writeToolTurnEnd and emit the loop between closeSpans() and error/finish-step. Either is a correctness fix; the ordering matters less than presence. A companion TestStreamModelWithTools_StreamEndEventErrorPairsToolChunks that feeds PartToolRequest → StreamEndEvent{Error} and asserts a tool-output-error for the orphaned call would lock this in. Fix this →


Minor / pre-existing

2. 🟣 (pre-existing) StreamModel non-executor path leaves orphans on all three error paths

adapter/vercelaisdk/uimessagestream/handler.go:411-473

Carrying forward from round 5: StreamModel (the no-executor path) emits tool-input-start/tool-input-available via handleContentPart (line 343-352) → writeToolRequest (line 292-313), but unlike streamToolTurn it does not track pending tool calls anywhere. On iterator-error (lines 429-442), StreamResetEvent (458-465), and StreamEndEvent.Error via writeStreamEnd (388-407), the previously-emitted tool-input-available chunks are left unpaired.

Reachable through Handler(model, WithTools(tools, nil))WithTools accepts a nil executor with no check (handler.go:87-92), ServeHTTP sets req.Tools regardless (handler.go:170), and StreamModelWithTools delegates to StreamModel when executor == nil (handler.go:481-484). Also reachable via direct exported StreamModel calls with req.Tools populated.

Lower priority since the standard tool-calling config routes through streamToolTurn. Fix options: (a) track pending tool IDs in StreamModel (small struct field on streamWriter) and mirror the same cleanup-loop pattern on all three error paths; (b) reject WithTools(tools, nil) at config time (closes route 1 but not direct callers); (c) document tools-without-executor as unsupported and leave the orphan as a documented protocol behavior for client-side execution.

3. 🟣 (pre-existing) Outbound text/reasoning lost across agentic turns

adapter/vercelaisdk/uimessagestream/handler.go:517-522

Carried forward from round 4 review. The assistant message constructed for next-turn history (lines 517-522) is built only from toolRequests. Any text or reasoning the model emitted alongside its tool call is streamed to the client but dropped from the conversation history passed to the next model turn. With the new WithMaxTurns default of 10, divergence amplifies on longer agentic chains.

Tracked as a known limitation — but the existing Known limitations doc block (handler.go:17-21) only covers the symmetric inbound gap. Worth either extending the doc block to mention the outbound gap, or accumulating text/reasoning parts in handleToolTurnPart alongside the tool requests and including them in assistantParts.


What's good (round 6)

  • Round 5's three symmetric tool-input orphan paths — iterator-error and StreamResetEvent are now correctly paired with tool-output-error cleanup loops (lines 564-569, 599-604), and the test suite directly proves both: TestStreamModelWithTools_IteratorErrorPairsToolChunks (handler_test.go:1543) and TestStreamModelWithTools_StreamResetEvent (handler_test.go:1635).
  • ErrorEvent case added to streamToolTurn (lines 583-588) — closes the asymmetry with StreamModel's ErrorEvent handling. Covered by TestStreamModelWithTools_ErrorEventNonTerminal (handler_test.go:1745).
  • iterReq now uses shallow clone (iterReq := *req; iterReq.Messages = messages at handler.go:549-550) — Options, ResponseFormat, Metadata, and ToolChoice are now forwarded to subsequent model turns. Covered by TestStreamModelWithTools_RequestFieldsForwarded (handler_test.go:1790), which asserts Options is captured on both turns.
  • PartToolResponse in handleContentPart (handler.go:353-362) now closes both spans symmetrically with PartToolRequest. Covered by TestStreamModel_ToolResponseClosesSpans (handler_test.go:1696), which asserts text IDs differ across the tool-response boundary.
  • Early-return guard semantics: the finishReason == "" check at line 506 now cleanly distinguishes "stream aborted" (ctx cancel or write failure inside streamToolTurn) from terminal-error cases, with no over-triggering against StreamEndEvent.Error. The round 4 regression is gone.
  • Diff stays focused — each commit (b93c88f, 06f0a7a, 67b2772) addresses one symmetric path with a targeted test.

View job run • Branch: jb/vercel-aisdk-review-fixes

Third and final symmetric error path: when StreamEndEvent carries an
error after tool-input chunks have been written, emit tool-output-error
for each pending request. Completes the set alongside iterator-error
(b93c88f) and StreamResetEvent (06f0a7a).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@birdayz
Copy link
Copy Markdown
Contributor Author

birdayz commented May 12, 2026

@claude review

@claude
Copy link
Copy Markdown

claude Bot commented May 12, 2026

Claude Code Review

Latest commit be957b1 (Pair orphaned tool-input on StreamEndEvent.Error in streamToolTurn) addresses round 6's blocking issue. The fix is correct and covered by TestStreamModelWithTools_StreamEndEventErrorPairsToolChunks. Remaining items are a minor protocol-ordering asymmetry between the three error paths and the two pre-existing items previously noted.

Completed

  • Read changed files
  • Verify previously-flagged blocker (round 6 issue initial commit #1)
  • Look for new issues
  • Post review

Round 6 blocker — addressed

StreamEndEvent.Error orphan tool-input — fixed at adapter/vercelaisdk/uimessagestream/handler.go:608-620 (commit be957b1):

case llm.StreamEndEvent:
    finishReason = writeToolTurnEnd(e, sw, ew, logger)

    if e.Error != nil {
        for _, tr := range toolRequests {
            _ = ew.WriteChunk(Chunk{
                "type": "tool-output-error", "toolCallId": tr.ID,
                "errorText": "stream error; tool call discarded",
            })
        }

        toolRequests = nil
    }

All three symmetric error paths in streamToolTurn (iterator-error at lines 564-569, StreamResetEvent at lines 599-604, StreamEndEvent.Error at lines 611-619) now emit tool-output-error for previously-streamed tool inputs. TestStreamModelWithTools_StreamEndEventErrorPairsToolChunks (handler_test.go:1591-1636) directly asserts the protocol pairing: feeds PartToolRequest("call-orphan") → StreamEndEvent{Error} and asserts tool-output-error references call-orphan.


Issues

1. 🟡 (consistency nit) tool-output-error placement differs across the three error paths

adapter/vercelaisdk/uimessagestream/handler.go:562-574, 590-606, 608-620

The three error paths now all emit tool-output-error for orphaned tool requests, but at different positions in the chunk sequence:

  • Iterator-error (lines 562-574): closeSpanstool-output-error × Nerrorfinish-step (tool outputs inside the step, before error).
  • StreamResetEvent (lines 590-606): endTextAndAdvanceendReasoningtool-output-error × N (non-terminal — stream continues with a new step).
  • StreamEndEvent.Error (lines 608-620): writeToolTurnEnd emits errorcloseSpansfinish-step, then the new loop emits tool-output-error × N (tool outputs outside the step, after finish-step).

Both terminal paths preserve protocol pairing (Vercel useChat keys on toolCallId), so this is correctness-neutral — but the asymmetry is observable on the wire. Compare the two test assertions to see it:

// TestStreamModelWithTools_IteratorErrorPairsToolChunks (line 1582-1588):
"tool-input-start", "tool-input-available",
typeToolOutputError,                       // INSIDE step
typeError, typeFinishStep, typeFinish,

// TestStreamModelWithTools_StreamEndEventErrorPairsToolChunks (line 1622-1628):
"tool-input-start", "tool-input-available",
typeError, typeFinishStep,
typeToolOutputError,                       // OUTSIDE step
typeFinish,

The cleanest fix is to thread toolRequests into writeToolTurnEnd so the error path can emit the cleanup loop between closeSpans() and finish-step, matching iterator-error's INSIDE-step placement. Or restructure to make iterator-error emit tool-output-error outside the step. Either choice is fine; pick one and apply uniformly. Not a blocker. Fix this →


Pre-existing (still flagging for visibility)

2. 🟣 StreamModel (non-executor path) leaves orphans on all three error paths

adapter/vercelaisdk/uimessagestream/handler.go:411-473

Carried forward from round 6. StreamModel emits tool-input-start/tool-input-available via handleContentPartwriteToolRequest, but unlike streamToolTurn it does not track pending tool calls anywhere. On iterator-error (lines 429-442), StreamResetEvent (458-465), and StreamEndEvent.Error via writeStreamEnd (388-407), the previously-emitted tool-input-available chunks are left unpaired.

Reachable through Handler(model, WithTools(tools, nil))WithTools accepts a nil executor with no check (handler.go:87-92), ServeHTTP sets req.Tools regardless (handler.go:170), and StreamModelWithTools delegates to StreamModel when executor == nil (handler.go:481-484). Also reachable via direct exported StreamModel calls with req.Tools populated.

Lower priority since the standard tool-calling config routes through streamToolTurn. Options: (a) track pending tool IDs in StreamModel and apply the same cleanup-loop pattern; (b) reject WithTools(tools, nil) at config time; (c) document tools-without-executor as a documented client-side-execution flow where orphans are intentional.

3. 🟣 Outbound text/reasoning lost across agentic turns

adapter/vercelaisdk/uimessagestream/handler.go:517-522

Carried forward from rounds 4-6. The assistant message constructed for next-turn history (lines 517-522) is built only from toolRequests. Any text or reasoning the model emitted alongside its tool call is streamed to the client but dropped from the conversation history passed to the next model turn. With the new WithMaxTurns default of 10, divergence amplifies on longer agentic chains.

The existing Known limitations doc block (handler.go:17-21) only covers the symmetric inbound gap. Worth either extending it to mention the outbound gap, or accumulating text/reasoning parts in handleToolTurnPart alongside the tool requests and including them in assistantParts.


What's good (round 7)

  • All three symmetric tool-input orphan paths now paired: iterator-error, StreamResetEvent, and StreamEndEvent.Error each emit tool-output-error for previously-streamed tool inputs. The protocol pairing invariant is preserved across every terminal-error path that streamToolTurn can take.
  • TestStreamModelWithTools_StreamEndEventErrorPairsToolChunks (handler_test.go:1591) directly proves the new fix: feeds PartToolRequest("call-orphan") → StreamEndEvent{Error}, asserts the exact chunk sequence including tool-output-error referencing call-orphan.
  • Diff continues to be tight and focusedbe957b1 adds exactly 10 lines (loop + nil-out) plus the targeted test, no unrelated changes.
  • Symmetry of test patterns: the three error-path orphan-pairing tests (TestStreamModelWithTools_IteratorErrorPairsToolChunks, TestStreamModelWithTools_StreamResetEvent, TestStreamModelWithTools_StreamEndEventErrorPairsToolChunks) now form a coherent suite that locks in the pairing invariant.

View job run • Branch: jb/vercel-aisdk-review-fixes

Comment on lines 510 to 512
if len(toolRequests) == 0 || finishReason != "tool-calls" {
_ = ew.WriteChunk(Chunk{"type": "finish", "finishReason": finishReason})
_ = ew.WriteDone()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Tool-input chunks emitted before a StreamEndEvent with FinishReason length/content-filter/other are orphaned: streamToolTurn returns ("length", [pending]) and StreamModelWithTools at handler.go:510-515 writes finish+[DONE] without iterating toolRequests, leaving every Vercel useChat tool-call card with no matching tool-output-* chunk. The PR added this cleanup for the iterator-error (lines 564-569) and StreamResetEvent (lines 599-604) paths but missed the third symmetric path. Fix: emit tool-output-error for each pending tool request before the finish chunk, mirroring the two existing loops.

Extended reasoning...

What the bug is

The early-return at handler.go:510-515 fires whenever streamToolTurn returns with finishReason != "tool-calls", but does not iterate the accumulated toolRequests slice to pair each previously-emitted tool-input-available with a tool-output-* chunk. Two of the three symmetric paths in streamToolTurn were just fixed for exactly this orphan-pairing problem (lines 564-569 for iterator-error and lines 599-604 for StreamResetEvent), but the StreamEndEvent path was missed for the e.Error == nil case.

Specific code path that triggers it

Inside streamToolTurn, StreamEndEvent delegates to writeToolTurnEnd (handler.go:653-672). When e.Error == nil and e.Response.FinishReason is anything other than FinishReasonToolCalls, mapFinishReason translates it to "stop", "length", "content-filter", or "other". None of those equal "tool-calls", so the caller's early-return triggers with toolRequests still populated.

Why existing code doesn't prevent it

The iterator-error and StreamResetEvent branches both have explicit cleanup loops added in this PR. The StreamEndEvent path returns the mapped reason and the caller fall-through assumed toolRequests is empty when finishReason isn't "tool-calls" — which is true when the model finishes cleanly without tool calls, but not when length/content-filter cuts off mid-tool-emission.

Reachability is well-documented across every in-repo provider

This is the documented, intentional mapping for the in-repo providers:

  • providers/anthropic/response_mapper_test.go:92 — "max_tokens with tool calls stays Length"
  • providers/anthropic/response_mapper_test.go:104 — "refusal with tool calls stays ContentFilter"
  • providers/anthropic/stream_partial_test.go:139 — "max_tokens must propagate as FinishReasonLength even when tool calls are present"
  • providers/bedrock/finish_reason_test.go:65,71 — Length/ContentFilter preserved with tool_use
  • providers/openai/response_mapper_test.go:100,107 — Length/ContentFilter with tool calls
  • providers/google/finish_reason_test.go:42-45 — same
  • providers/openaicompat/finish_reason_test.go:43-45 — same

So the moment any model truncates mid-tool-emission (long arguments, large schemas, content filter cutoff), this fires.

Step-by-step proof

Provider yields [ContentPartEvent{PartToolRequest{ID:"call-1"}}, StreamEndEvent{Response: {FinishReason: FinishReasonLength}}] to StreamModelWithTools:

  1. StreamModelWithTools writes start, enters loop, calls streamToolTurn.
  2. streamToolTurn writes start-step.
  3. ContentPartEvent{PartToolRequest{call-1}}handleToolTurnPart PartToolRequest case → appends call-1 to toolRequests, writeToolRequest emits tool-input-start + tool-input-available. State: toolRequests=[call-1].
  4. StreamEndEvent{Response:{FinishReason:Length}} (e.Error nil) → writeToolTurnEnd emits finish-step, returns "length" via mapFinishReason. No tool-output- for call-1.*
  5. streamToolTurn returns ("length", [call-1]).
  6. Caller: len(toolRequests)>0 is false branch, but finishReason != "tool-calls" is true → enters if block at handler.go:510. Writes finish with reason="length", [DONE], returns.

Wire output:

start, start-step,
tool-input-start, tool-input-available,   ← call-1 orphaned
finish-step,
finish (length), [DONE]

The tool-input-available for call-1 has no matching tool-output-available or tool-output-error. Vercel useChat tracks tool-call state by toolCallId — call-1 enters pending and never leaves.

Impact

UI clients render the tool-call card as perpetually pending: permanent loading spinner, unresolved error overlay, or a stale entry in messages[] that contradicts the assistant's truncated response. The same pairing invariant that the PR just enforced for the other two error paths.

Fix

Mirror the cleanup loops already present at lines 564-569 and 599-604. In StreamModelWithTools at handler.go:510, before writing the finish chunk:

if len(toolRequests) == 0 || finishReason != "tool-calls" {
    for _, tr := range toolRequests {
        _ = ew.WriteChunk(Chunk{
            "type": "tool-output-error", "toolCallId": tr.ID,
            "errorText": "stream ended without tool resolution; tool call discarded",
        })
    }
    _ = ew.WriteChunk(Chunk{"type": "finish", "finishReason": finishReason})
    _ = ew.WriteDone()
    return
}

A companion test TestStreamModelWithTools_FinishLengthPairsToolChunks that feeds PartToolRequest → StreamEndEvent{FinishReason: Length} and asserts a tool-output-error precedes the finish would lock this in.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants