fix(core): split inline chain-of-thought into a reasoning part by ashaker20 · Pull Request #54612 · anomalyco/opencode · GitHub
Skip to content

fix(core): split inline chain-of-thought into a reasoning part - #54612

Open
ashaker20 wants to merge 2 commits into
anomalyco:devfrom
ashaker20:inline-reasoning-split
Open

ashaker20 wants to merge 2 commits into
anomalyco:devfrom
ashaker20:inline-reasoning-split

Conversation

@ashaker20

@ashaker20 ashaker20 commented Oct 11, 2026 •

Copy link
Copy Markdown

Issue for this PR

Closes #54599

Type of change

  • Bug fix

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 closing think tag, 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 for opencode/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 on compatibility.extractReasoning, emitting reasoning deltas/end through the existing lifecycle.
  • packages/ai/src/schema/options.ts, packages/schema/src/model.ts: new optional extractReasoning compatibility field.
  • packages/core/src/model.ts: Model.compatibility() parses interleaved.extract and applies the per-model default for opencode/big-pickle.
  • packages/core/src/models-dev.ts, packages/core/src/modal/models.ts, packages/core/src/v1/config/provider.ts: allow extract on interleaved.
  • Removed the previous message-updater.ts projection 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.ts covering: 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) - pass
  • bun test test/models.test.ts test/catalog.test.ts (packages/core) - pass
  • bun run typecheck in packages/ai, packages/core, packages/schema - clean

Screenshots / recordings

Not a UI change; the reasoning now arrives on the existing reasoning channel.

Checklist

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

@github-actions

Copy link
Copy Markdown
Contributor

@opencode-agent opencode-agent Bot 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.

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

Before

After

match.state = castDraft(event.data.state)
}
if (!match) return
const split = splitInlineReasoning(event.data.text)

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.

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*>/

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.

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.
@ashaker20
ashaker20 force-pushed the inline-reasoning-split branch from 2f7e3e0 to 53a9bdf Compare October 11, 2026 21:21
@github-actions github-actions Bot removed the needs:compliance This means the issue will auto-close after 2 hours. label Oct 11, 2026
@ashaker20

Copy link
Copy Markdown
Author

Reworked to address the review:

  • Moved the split to the stream parser (packages/ai/src/protocols/openai-chat.ts). It now emits real reasoning events, so live clients (packages/client/src/solid/data.ts, TUI rows.ts) and the stored message agree. Removed the message-updater.ts projection change and its test.
  • The reasoning part goes through the normal reasoning lifecycle, so it gets a finish time and is no longer treated as interrupted on the next turn.
  • The split is opt-in per model via compatibility.extractReasoning: "think-tags", applied to opencode/big-pickle, so other models keep their content untouched.
  • The splitter handles a leading opening tag, a trailing closing tag, and a marker split across streaming deltas.
  • Fixed the description; it now follows the PR template and names the marker correctly.

Verification: new scripted-SSE tests in packages/ai/test/provider/openai-chat.test.ts (reasoning before the closing tag, block wrapped in an opening tag, marker split across deltas, no marker, non-opted-in model), plus packages/core models/catalog tests. Typechecks clean in packages/ai, packages/core, and packages/schema.

@github-actions

Copy link
Copy Markdown
Contributor

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

@zzz-creator

Copy link
Copy Markdown

This branch has not been deployed

No deployments
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.

reasoning: opencode zen (big-pickle) inline </think> leaks into text content instead of a reasoning part

2 participants