Skip to content

fix(ai): absorb content after the finish reason - #49167

Closed
marwanvx wants to merge 3 commits into
anomalyco:devfrom
marwanvx:absorb-late-content
Closed

marwanvx wants to merge 3 commits into
anomalyco:devfrom
marwanvx:absorb-late-content

Conversation

@marwanvx

@marwanvx marwanvx commented Sep 15, 2026 •

Copy link
Copy Markdown

Issue for this PR

Closes #43040

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Some providers send a final content, reasoning, or tool call chunk after the chunk with finish_reason. SenseNova does it on every request with high reasoning effort, NVIDIA does it sometimes. Right now the guard from #40798 rejects those streams, so one trailing chunk throws away the whole step (reasoning, text, and tool calls) and fails the turn.

I read through the AI SDK source and it has no guard for this at all. It records finish_reason when the chunk arrives, keeps reading the late chunks as normal output, and sends finish once at the end. LiteLLM and the official SDKs just pass the late chunks through too. So this PR makes the OpenAI Chat and Mistral parsers do the same:

  1. Trailing chunks after finish_reason are kept as normal output instead of failing the response.
  2. Late tool calls accumulate their argument deltas and finalize once at stream end, so split arguments are complete. Earlier tool events are retained. Filtered and truncated finishes still drop unconfirmed tools, unchanged.
  3. Only the first terminal chunk rejects tool deltas with no id or name. Malformed late ones just get dropped instead of failing the stream.
  4. Streams that end with no finish_reason at all still fail like before.

Hit this myself on z-ai/glm-5.3-flash on NVIDIA, one late chunk wiped a full step mid task. Probed the endpoint with 5 tool requests and 2 long generations and the late chunk only shows up sometimes, which is the worst case for a hard error since it just looks like random flakes.

How did you verify your code works?

  • bun test in packages/ai: 1398 pass, 28 skip, 0 fail. Added regression coverage for repeated terminal finishes with split Mistral tool arguments, late reasoning, malformed late tool deltas, and isolated Mistral late-content handling.
  • bun typecheck in packages/ai: clean.
  • bun run lint: clean.
  • Repro for reviewers: content delta, finish chunk, then tool delta through the OpenAI Chat route. Old code errors with received content after the finish reason, new code emits the tool call and finishes. The Mistral parser has the same end-of-stream finalization and repeated-terminal regression coverage.

Screenshots / recordings

N/A, parser fix, no UI change.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

@github-actions github-actions Bot added needs:compliance This means the issue will auto-close after 2 hours. needs:issue labels Sep 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Thanks for your contribution!

This PR doesn't have a linked issue. All PRs must reference an existing issue.

Please:

  1. Open an issue describing the bug/feature (if one doesn't exist)
  2. Add Fixes #<number> or Closes #<number> to this PR description

See CONTRIBUTING.md for details.

@github-actions github-actions Bot removed the needs:compliance This means the issue will auto-close after 2 hours. label Sep 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Thanks for updating your PR! It now meets our contributing guidelines. 👍

@holny holny left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pulled your branch (16b4038) and ran the two affected suites here: 40 pass, 0 fail, including the three new cases. Accumulating toolCallEvents / completedTools instead of replacing them is the right call once a late frame can finalize more than once.

One shape the new tests don't cover, and it now degrades into a silently wrong result instead of a loud error:

Dropping the state.finishReason === undefined gate at openai-chat.ts:1082 makes finished recompute on every post-finish frame, and ToolStream.finishAll clears the tool map (tool-stream.ts:237). So a late call is finalized on the very first frame that carries its id/name. If the provider splits that late call across frames — identity first, arguments after — the arguments aren't there yet:

delta({content:"Hello"}) / delta({}, "stop")
delta({tool_calls:[{index:0,id:"call_1",function:{name:"lookup",arguments:'{"city":'}}]})
delta({tool_calls:[{index:0,function:{arguments:'"Paris"}'}}]})

Ran exactly that against your head: the response comes back as toolCalls: [{id:"call_1",name:"lookup",input:{}}]. The continuation chunk lands in pendingTools, and the missing-identity check at openai-chat.ts:1068 is skipped once state.finishReason is set, so it vanishes without a trace. Before this PR the same stream failed loudly; now it dispatches a tool call with empty input. Your "finishes a tool call absorbed after a terminal chunk" test can't catch it because the single late chunk already carries arguments:"{}".

finishAll is already reachable from finishEvents (openai-chat.ts:1118-1121), so would it be simpler to finalize late calls there — relax that guard to Object.keys(state.tools).length > 0 — and leave the per-frame path in step as it was? Late calls then finish once, at end of stream, with all argument deltas accumulated, and the split case above becomes a good test to pin it.

The same stream on Mistral is dropped rather than finalized: finished at mistral-chat.ts:693 only runs on a frame that carries finish_reason, and Mistral's finishEvents never converts state.tools, so a late tool call with complete arguments comes back as toolCalls: [] (also verified on your head). So "a tool call that arrives late gets finalized" holds for OpenAI Chat but not for Mistral — either fix both or narrow that part of the description.

Minor: hasLateContent still lets text and reasoning through after length / content_filter while late tools are dropped for incomplete finishes. Intentional? A late text chunk after content_filter ends up in response.text.

@marwanvx

Copy link
Copy Markdown
Author

Confirmed both shapes on my head before fixing: the split late call came back as input:{} on OpenAI Chat, and the late Mistral call came back as toolCalls: [].

Fixed per your suggestion:

  • OpenAI Chat restores the first-terminal-only gate on the per-frame finishAll (openai-chat.ts:1082). Late deltas accumulate in tools only; nothing finalizes mid-stream.
  • Late tools finalize once in finishEvents when the finish is not length/content-filter (openai-chat.ts:1118), accumulating [...state.toolCallEvents, ...late.events]. The filtered-drop test still passes unchanged.
  • Mistral gets the same end-of-stream finalization in its finishEvents (mistral-chat.ts:709) into completedTools, so late calls finalize on both protocols. No per-frame change on Mistral, avoiding the same split bug.
  • Accumulation instead of replacement stays, since a late frame can now only finalize once at the end.

On the minor: yes, intentional. Text and reasoning after length/content-filter are additive output worth keeping; tool calls need confirmation, so they stay dropped for incomplete finishes.

Tests (+3): OpenAI split-args late call finishes with {city:"Paris"}; Mistral late complete call finalizes; Mistral split-args late call finishes with full arguments. Verified: target suites 43 pass, full ai suite 1394 pass / 28 skip / 0 fail, ai typecheck clean, lint clean.

@Mirrowel

Copy link
Copy Markdown

@Mirrobot-Agent check this PR out. Is this a good solution? What's the quality of this pr?

@Mirrobot-Agent

Mirrobot-Agent commented Sep 15, 2026 •

Copy link
Copy Markdown

Analysis done, writing the verdict now, @Mirrowel.

State of play after reading both parsers end to end and tracing the state machine through the new tests:

  • The rework in 96131f7 does close holny's split-args hole on OpenAI Chat — late deltas accumulate and finalize once in finishEvents, with tests pinning it. Good fix.
  • I traced the same shape on the Mistral side: the end-of-stream finalization is there, but one first-terminal gate that OpenAI Chat has didn't make it over — and this PR is what makes that path reachable. Details in the review; it's a one-condition fix.
  • One thing I can't do here: execute bun test (sandbox command profile). My verification is a code trace; the author's and holny's suite runs carry the execution evidence.

Verdict lands as its own review in a moment.

@Mirrobot-Agent Mirrobot-Agent left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Verdict: commented — one fix I'd ask for before merge (the Mistral parity gap below); everything else here is solid and well-tested.

Overall Assessment

The direction is right. #43040 is a real failure — the strict guard from #40798 discarded an entire step over one trailing chunk — and matching the AI SDK / LiteLLM / official-SDK pass-through behavior is the correct compatibility trade. The terminal-finish_reason requirement still guards stream completeness, and late chunks now land in the output they belong to.

The rework after holny's review is also right, and I verified the net diff removes his reproducer's failure: per-frame finishAll in openai-chat.ts is back behind state.finishReason === undefined (1083-1087), late deltas accumulate in tools/pendingTools, and finishEvents finalizes them once with complete arguments (1122-1132). The length / content-filter drop for unconfirmed tools is preserved on both parsers, and the new [...completedTools, ...finished.events] accumulations are consistent with finalization paths that can no longer repeat on OpenAI. The added tests pin the split-args case directly on both protocols.

One structural tradeoff worth recording, not blocking: absorbing trailing chunks unconditionally means provider misbehavior such as a duplicated trailing chunk is now tolerated instead of surfaced. That is the deliberate price of compatibility, the inline comments document the rationale well, and I don't think it needs more.

What keeps this from a clean pass is the one miss below: it's the same failure class that was just fixed on the sibling parser, and it's introduced by this PR's absorption change rather than pre-existing (no post-finish frame could reach finished on Mistral before).

🟠 Major

  • packages/ai/src/protocols/mistral-chat.ts:692-695 — finished is missing the first-terminal state.finishReason === undefined gate; a repeated terminal chunk with trailing content can finalize accumulated late tool calls early with partial arguments, and the continuation deltas are then silently dropped. (inline)

🟡 Minor

  • packages/ai/test/provider/openai-compatible-chat.test.ts:578-634 — malformed late tool deltas (dropped, not fatal) and late reasoning absorption are untested; both are cheap fixtures. (inline)
  • packages/ai/test/provider/mistral-chat.test.ts:645-679 — the late-content assertion is folded into an unrelated test; split it so failures attribute cleanly. (inline)

🔵 Info

  • PR description drift: item 2 still describes the pre-96131f7 behavior (late call "finalized in the same frame"); the head finalizes once at stream end. "Three new tests" is now five new plus two updated. A quick body refresh would keep the description matching what reviewers are approving.
  • Verification boundary: the Mistral sequence above is a static trace — I couldn't execute bun test in this environment, so the author's and holny's suite runs remain the execution evidence. Confirming the traced case with a test would close both that and the coverage ask.

Review Warnings

  • Could not run bun test / bun typecheck (command profile in this sandbox); all verification here is by reading the head source and tracing the parser state machine against the new tests.

Questions for the Author

  • Was omitting state.finishReason === undefined from Mistral's finished deliberate, or an oversight? If repeated-terminal frames are considered out of scope, a one-line comment saying so (like the others you added) is enough for me.

cc @marwanvx

Posted as a COMMENT review (non-collaborator account — REQUEST_CHANGES isn't available); the ask above stands on its own.
This review was generated by an AI assistant.

// Only the first terminal arrival rejects unidentifiable tool deltas; late
// frames after a finish absorb malformed tool identities silently instead of
// failing the response.
if (state.finishReason === undefined && !incomplete && Object.keys(withTools.pendingTools).length > 0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 Major — This gates the missing-name check on first terminal arrival, but the finished computation just below (mistral-chat.ts:692-695) still lacks the matching state.finishReason === undefined guard that openai-chat.ts:1083-1087 has — and this PR is what makes that path reachable. Before it, every post-finish frame returned early or failed, so per-frame finishAll could only ever run on the first terminal chunk; now a trailing chunk that repeats the terminal finish_reason and carries content (or a tool delta) skips the early return at line 663, reaches finished, and finalizes every accumulated late tool call on the spot with only the arguments received so far.

Trace for:

delta({}, "stop")
delta({tool_calls:[{index:0,id:"Ab12Cd34E",function:{name:"lookup",arguments:'{"city":'}}]})
delta({content:"!"}, "stop")   // repeated terminal + late content
delta({tool_calls:[{index:0,function:{arguments:'"Paris"}'}}]})

Frame 3 finalizes the call with {"city": (partial-parse fallback), clears tools, and frame 4's continuation lands in pendingTools where finishEvents never looks — the response dispatches lookup with empty input, silently. That is the same failure class holny found in the previous revision for OpenAI Chat; here it needs a provider that keeps finish_reason populated on its trailing chunk, which is one of the shapes this PR exists to absorb.

Adding the first-terminal gate defers finalization to finishEvents, where the arguments are complete:

  const finished =
    state.finishReason === undefined &&
    !incomplete &&
    Object.keys(withTools.tools).length > 0
      ? yield* ToolStream.finishAll(ADAPTER, withTools.tools)
      : undefined

(Plain code block rather than a GitHub suggestion because those lines sit just outside the diff hunk.) A test mirroring the OpenAI split-args case with a repeated finish_reason would pin it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Confirmed the Mistral issue with a repeated terminal finish and split tool arguments. Without the guard, partial JSON became {} and the continuation was dropped.

Fixed in a012e57d00 by restricting per-frame Mistral finalization to the first terminal arrival. Added regression coverage for this case, malformed late tools, late reasoning, and isolated Mistral late-content handling.

Verification:

  • bun test: 1398 pass, 28 skip, 0 fail
  • bun typecheck: clean
  • bun run lint: clean

The PR description is updated. Please re-review.

),
)

expect(response.toolCalls).toMatchObject([{ id: "call_1", name: "lookup", input: { city: "Paris" } }])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Minor — Two behaviors this PR introduces are still unasserted:

  1. Malformed late tool deltas are dropped, not fatal (your point 3). Nothing here fails today if that regresses into an InvalidProviderOutput: a fixture like deltaChunk({ tool_calls: [{ index: 1, function: { arguments: "{}" } }] }) after the terminal chunk should finish the stream with toolCalls: [] and no error.
  2. Late reasoning is absorbed — the motivating SenseNova shape. A trailing deltaChunk({ reasoning_content: "..." }) should surface in response.reasoning; only late text is covered today.

Both are single-fixture additions to this suite.

Effect.flip,
)
expect(lateContent.message).toContain("content after the finish reason")
expect(lateContent.text).toBe("late")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Minor — The late-content assertion now lives inside "ignores non-text output parts and rejects invalid stream endings", making that test responsible for three unrelated behaviors and its failures ambiguous. Splitting it into its own it.effect("absorbs content after a terminal chunk", ...) — the name the OpenAI-compatible suite already uses — keeps the two suites symmetrical.

@kvnloo

kvnloo commented Sep 15, 2026

Copy link
Copy Markdown

Why this matters

OpenAI-compatible providers sometimes emit a final content/reasoning/tool chunk after finish_reason. The #40798 hard fail drops the whole step (#43040).

Evidence

Tip a012e57 (base v2):

  • packages/ai/src/protocols/openai-chat.ts + mistral-chat.ts — absorb late deltas when hasLateContent; only idle when finish is set and no late content
  • tests: openai-compatible-chat.test.ts, mistral-chat.test.ts (author: late-call shapes fail→pass)
  • CI: standards / compliance success

Confirms scope — matches AI SDK/LiteLLM pass-through. Happy to help land as-is.

Ask (design, light) — prospective

  1. Is post-finish content a provider quirk forever, or should OpenAI-compatible adapters treat finish as advisory until stream end (class policy)?
  2. For never-again: shared late-delta helper across chat protocols, or per-adapter absorbs + one conformance matrix (SenseNova / NVIDIA / …)?

@pwnedbygary

Copy link
Copy Markdown

Independent reproduction on Linux with @opencode/cli v2.0.15 using the built-in opencode/space-bunny-free model (not a custom provider): opencode run --standalone --model opencode/space-bunny-free "Reply with exactly OK." printed OK and then exited with OpenAI Chat received content after the finish reason. The same error interrupted an OpenChamber session twice within five minutes. This confirms #43040 affects the hosted free model as well. Thank you for the parser fix and regression coverage here.

@pwnedbygary

Copy link
Copy Markdown

Local before/after verification for #43040: stock Linux @opencode/cli v2.0.15 with opencode run --standalone --model opencode/space-bunny-free "Reply with exactly OK." prints OK, then exits 1 with OpenAI Chat received content after the finish reason. I applied the OpenAI Chat parser change from this PR to the exact v2.0.15 tag, built the Linux x64 CLI, and reran that prompt four times; all exited 0 without the parser error. The patched output is OKOK (the trailing duplicate content is now absorbed), so this fixes the failed turn but does not deduplicate provider output. The focused OpenAI-compatible suite passes (21/21), as does package typecheck. OpenChamber preview is running normally with the patched v2.0.15 build and its /api/info endpoint. Note: building this PR head directly was too old for the current OpenChamber preview (/api/info returned 404); applying the parser fix onto v2.0.15 resolved that compatibility issue.

@github-actions

Copy link
Copy Markdown
Contributor

Automated PR Cleanup

Thank you for contributing to opencode.

Due to the high volume of PRs from users and AI agents, we periodically close older PRs using automated criteria so maintainers can focus review time on the most active and community-supported contributions.

This PR was closed because it matched the following cleanup criteria:

  • The PR was created more than 1 month ago
  • The PR had fewer than 2 positive reactions
  • Positive reactions are counted as thumbs-up, heart, celebration, or rocket reactions on the PR

PRs created within the last month are not affected by this cleanup.

If you believe this PR was closed incorrectly, or if you are still actively working on it, please leave a comment explaining why it should be reopened. A maintainer can review and reopen it if appropriate.

Thanks again for taking the time to contribute.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OpenAI Chat adapter throws eceived content after the finish reason on OpenAI-compatible providers with reasoning fields

6 participants