Repository navigation
Conversation
There was a problem hiding this comment.
Thanks for picking this up. I ran the bot's reproduction for #54599 (a simulated model that sends reasoning</think>answer in content) on the base and on this branch, running OpenCode from source. With this change, the TUI still shows the reasoning, the literal </think> and the answer as one answer-text block, with no Thought block (screenshots below). The unit tests and packages/core typecheck pass.
The main problem is that the split only happens in the server's stored copy of the message (message-updater.ts). Clients build the live message from the stream events themselves: packages/client/src/solid/data.ts and the TUI's rows.ts just set the text on session.text.ended. So while the session is open, users see no change, and what the client shows differs from what is stored. The repro notes for #54599 suggested doing this split at the stream level in packages/ai (protocols/openai-chat.ts), opt-in per model or provider, so that real session.reasoning.* events are sent. Then every client and the stored message agree.
Other concerns are in the inline comments: the new reasoning part never gets a finish time, which breaks how the whole message is replayed to the model, and the split runs for every model with no opt-in.
Also, the description says the marker is a literal response, but the code splits on </think>.
Public API: unchanged (no change to the SDK, HTTP API, config or events).
Related: #54298
| match.state = castDraft(event.data.state) | ||
| } | ||
| if (!match) return | ||
| const split = splitInlineReasoning(event.data.text) |
There was a problem hiding this comment.
This only changes the server's stored copy of the message. The live client store (packages/client/src/solid/data.ts) and the TUI rows apply session.text.ended themselves, so they keep showing a single text part, as I saw in the TUI. The stored copy also gains an extra reasoning part that the stream events never announced, so it no longer matches what clients show. A splitter where the stream is parsed (packages/ai, e.g. protocols/openai-chat.ts) that sends real reasoning events would fix every consumer at once.
This reasoning part gets a start time (time.created) but never a finish time (time.completed). On the next turn, to-llm-message.ts treats any reasoning part like that as interrupted (reasoningInterrupted). Every reasoning part in the message, including genuine provider reasoning, then loses its provider metadata, and the split-out reasoning is sent back to the model as plain text. rows.ts and group-view.tsx in the TUI also treat a reasoning part without a finish time as unfinished. At the very least it should be created finished.
| return project(event) | ||
| } | ||
|
|
||
| const inlineReasoningMarker = /<\/think\s*>/ |
There was a problem hiding this comment.
This applies to every provider and model with no opt-in. Any answer that legitimately contains </think>, such as code or docs about think tags or an explanation of model output, will have everything before it moved into a reasoning part. In the replay path above, that text then goes back to the model as text in a different place. Could this be limited to the models that actually do this (per model or provider option, as suggested in #54599)? It also doesn't handle a leading <think>.
Some OpenAI-compatible models leak their chain of thought into the text content channel, terminated by a closing think tag, instead of using the reasoning field. The previous attempt split this in the server's stored copy only (message-updater.ts), so live clients that build messages from stream events disagreed with what was stored and the TUI showed no change. Split it where the stream is parsed instead, so real reasoning events are emitted and every consumer agrees. Opt in per model through compatibility.extractReasoning: "think-tags", applied to opencode/big-pickle. The splitter handles a leading opening tag, a trailing closing tag, and markers split across deltas.
2f7e3e0 to
53a9bdf
Compare
|
Reworked to address the review:
Verification: new scripted-SSE tests in |
|
Thanks for updating your PR! It now meets our contributing guidelines. 👍 |



Issue for this PR
Closes #54599
Type of change
What does this PR do?
Some OpenAI-compatible models (including
opencode/big-pickle) intermittently stream their chain of thought inline in the text content, ended by a closingthinktag, instead of sending it on the reasoning field. OpenCode stored and displayed that reasoning as ordinary assistant text.The first version of this PR split it while projecting the stored message in
message-updater.ts. That was the wrong layer: clients build the live message from the stream events themselves, so the TUI still showed a single text part and the stored copy no longer matched what clients displayed. The reasoning part it created also had no finish time, so on the next turn the whole message was treated as having interrupted reasoning.This moves the split to where the stream is parsed, in
packages/ai/src/protocols/openai-chat.ts, so real reasoning events are emitted. Every client now agrees with the stored message, and the reasoning part gets a proper start and finish time.It is opt-in per model through
compatibility.extractReasoning: "think-tags", set foropencode/big-pickle, so content that legitimately contains the marker is left alone for every other model. The splitter handles a leading opening tag, a trailing closing tag, and a marker split across streaming deltas.Changes:
packages/ai/src/protocols/openai-chat.ts: streaming splitter gated oncompatibility.extractReasoning, emitting reasoning deltas/end through the existing lifecycle.packages/ai/src/schema/options.ts,packages/schema/src/model.ts: new optionalextractReasoningcompatibility field.packages/core/src/model.ts:Model.compatibility()parsesinterleaved.extractand applies the per-model default foropencode/big-pickle.packages/core/src/models-dev.ts,packages/core/src/modal/models.ts,packages/core/src/v1/config/provider.ts: allowextractoninterleaved.message-updater.tsprojection split and its test.How did you verify your code works?
Added scripted-SSE tests (no network) in
packages/ai/test/provider/openai-chat.test.tscovering: reasoning before a closing tag, a block wrapped in an opening tag, a marker split across deltas, plain content with no marker, and a non-opted-in model left untouched.bun test test/provider/openai-chat.test.ts(packages/ai) - passbun test test/models.test.ts test/catalog.test.ts(packages/core) - passbun run typecheckin packages/ai, packages/core, packages/schema - cleanScreenshots / recordings
Not a UI change; the reasoning now arrives on the existing reasoning channel.
Checklist