Repository navigation
Conversation
|
Thanks for your contribution! This PR doesn't have a linked issue. All PRs must reference an existing issue. Please:
See CONTRIBUTING.md for details. |
|
Thanks for updating your PR! It now meets our contributing guidelines. 👍 |
holny
left a comment
There was a problem hiding this comment.
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.
|
Confirmed both shapes on my head before fixing: the split late call came back as Fixed per your suggestion:
On the minor: yes, intentional. Text and reasoning after Tests (+3): OpenAI split-args late call finishes with |
|
@Mirrobot-Agent check this PR out. Is this a good solution? What's the quality of this pr? |
|
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:
Verdict lands as its own review in a moment. |
Mirrobot-Agent
left a comment
There was a problem hiding this comment.
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—finishedis missing the first-terminalstate.finishReason === undefinedgate; 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-
96131f7behavior (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 testin 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 === undefinedfrom Mistral'sfinisheddeliberate, 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) |
There was a problem hiding this comment.
🟠 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.
There was a problem hiding this comment.
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 failbun typecheck: cleanbun run lint: clean
The PR description is updated. Please re-review.
| ), | ||
| ) | ||
|
|
||
| expect(response.toolCalls).toMatchObject([{ id: "call_1", name: "lookup", input: { city: "Paris" } }]) |
There was a problem hiding this comment.
🟡 Minor — Two behaviors this PR introduces are still unasserted:
- Malformed late tool deltas are dropped, not fatal (your point 3). Nothing here fails today if that regresses into an
InvalidProviderOutput: a fixture likedeltaChunk({ tool_calls: [{ index: 1, function: { arguments: "{}" } }] })after the terminal chunk should finish the stream withtoolCalls: []and no error. - Late reasoning is absorbed — the motivating SenseNova shape. A trailing
deltaChunk({ reasoning_content: "..." })should surface inresponse.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") |
There was a problem hiding this comment.
🟡 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.
Why this mattersOpenAI-compatible providers sometimes emit a final content/reasoning/tool chunk after EvidenceTip
Confirms scope — matches AI SDK/LiteLLM pass-through. Happy to help land as-is. Ask (design, light) — prospective
|
|
Independent reproduction on Linux with |
|
Local before/after verification for #43040: stock Linux |
|
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:
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. |
Issue for this PR
Closes #43040
Type of change
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:
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 testin 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 typecheckin packages/ai: clean.bun run lint: clean.Screenshots / recordings
N/A, parser fix, no UI change.
Checklist