You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Bug: reasoning_content is replayed in the request history, causing tool-call markup leakage #56352
opencode persists the model's reasoning_content (chain-of-thought) in the session history and sends it back in every subsequent request. DeepSeek's API states that reasoning_content is the current turn's CoT and must not be passed back.
Impact: with reasoning models (e.g. deepseek-v4.1-flash), the model imitates patterns from its own past reasoning - including malformed tool-call markup - so tool calls are silently lost (tool_calls == 0, finish_reason == "stop"). It is self-reinforcing: the more it leaks, the more that markup appears in reasoning.
A/B (one captured opencode request, 1011 messages / 4.78 MB, replayed to the same endpoint with a "write a ~120-line script" instruction appended):
variant
leak rate
with reasoning_content
4 / 5
without reasoning_content
0 / 5
last 300 msgs, with
2 / 5
last 300 msgs, without
0 / 5
"Leak" is defined strictly as: markup inside delta.contentandtool_calls == 0andfinish_reason == "stop".
Observed message structure in the captured request: assistant messages 480, of which reasoning_content present in 480.
Excluded by A/B: max_tokens (256k vs 128k -> 4/5 vs 4/5), system prompt content, temperature, tool_choice, parallel_tool_calls, thinking/reasoning_effort fields, and endpoint (official api.deepseek.com vs the opencode gateway). The dose-response (100 msgs 0/5 -> 300 msgs 2-3/5 -> 1009 msgs 4/5) is fully explained by the replayed CoT.
A different client (CodeBuddy) with the same model and the same real leaked-text corpus in a 1.13 MB history does not reproduce it (0/5).
Suggested fix: do not send reasoning_content back when building the request payload (keep it for UI display only); optionally gate behind a provider/model capability flag.
Reproduction: run a long session with a reasoning model, then ask it to write a long file - occasionally the tool call is emitted as text; re-sending the same request with reasoning_content stripped removes the failure (0/5 vs 4/5).
Environment: opencode 2.0.30 (Windows), providers opencode-go and official deepseek, model deepseek-v4.1-flash (variant max).
I checked the code for 2.0.30, and the request side matches what you describe. For any DeepSeek model (matched by provider, base URL or model ID), OpenCode sends reasoning_content back on every earlier assistant message, including turns before the latest user message. That's why all 480 assistant messages in your capture have it.
I can't reproduce the leak itself here. It depends on how deepseek-v4.1-flash responds to that history, and testing that needs a real DeepSeek or OpenCode Go account, which this sandbox doesn't have. A maintainer with access will need to confirm it.
One thing to consider for a fix: removing reasoning_content entirely probably isn't safe. DeepSeek needs it passed back inside a tool-call turn, and requests without it are rejected (see #55096 and #55104). A narrower option would be to drop it only for assistant messages before the latest user message. If you still have your A/B setup, could you replay your captured request that way (keep reasoning_content within the current turn, remove it from earlier turns) and share the leak rate? Please also say whether the API accepts that request. That would make the case much stronger.
Setup: one captured request (1011 messages / 4.78 MB) replayed against the same endpoint
(opencode.ai/zen/go/v1, deepseek-v4.1-flash), with a "write a ~120-line script" instruction
appended. 5 runs per variant.
variant
leak rate
API errors
keep reasoning_content everywhere (baseline)
4/5
0
keep it only in the LAST assistant message (~current turn)
1/5
0
strip it everywhere
0/5
0
"Leak" is defined strictly as: markup inside delta.contentandtool_calls == 0and finish_reason == "stop". (A naive substring count gives false positives, because the model
legitimately discusses those strings in reasoning_content.)
Narrowing to "current turn only" reduces the leak substantially (4/5 -> 1/5) but does not fully
remove it in our sample; stripping everywhere gave 0/5. If passing reasoning_content back
inside a tool-call turn is required, the narrow fix looks like a reasonable compromise - though
note the sample is small (n=5).
One caveat worth flagging:#50206 mentions the same markup-as-text symptom being reported with other clients too. If that is accurate, replayed reasoning_content may not be the only trigger.
In our environment the A/B above is a clean controlled comparison (same request, same endpoint, same
model, only the reasoning_content fields differ), so it is at least a sufficient cause here.
A deterministic trigger for this leak: interrupting a tool call, then continuing the same session.
This may be the reproduction case you're missing — you don't need to wait for a 1000-message session to hit it, and it doesn't depend on luck: interrupt a running shell tool, send any follow-up message in the same session, and the very next assistant turn leaks markup.
Environment
opencode v2.2.0, Linux Mint 21.3 (kernel 6.8.0), node 22.22.3, Zed via opencode acp
provider A: custom @opencode/ai/providers/openai-compatible → local OpenAI-compatible proxy, deepseek-v4.1-flash
provider B: built-in opencode provider (zen), deepseek-v4.1-flash-free — same leak, after a server restart mid-session
provider config includes compatibility.reasoningField: "reasoning_content", so the reasoning channel is being parsed correctly; the stored parts in this session carry type: "reasoning" with native.reasoningField: "reasoning_content"
There is no content key under state: the interrupted tool call is persisted without any result. So on the next request the history contains an assistant turn with a tool call and no matching tool result — on top of a replayed reasoning trace from the interrupted turn (which is the reasoning_content-replay condition this issue describes). Both of #55096's ingredients are present.
Steps to reproduce
Ask the agent to run a long shell command (e.g. a poll loop of ~2 minutes).
Interrupt the tool (stop button / esc / ACP session/cancel).
Send any follow-up message in the same session.
The next turn emits the reasoning terminator as text, then a raw tool-call JSON as text, finish: "stop", no tool executed.
How often
770 assistant messages in this install's session_message contain a tool part with "type":"aborted" — the missing-result condition is common, not exotic.
Control: same model, same proxy, clean 5-tool-call task (read 3 files → write summary → re-read) → correct finish values, no markup. Plugins disabled → identical result. So it is not a plugin, not the tool loop, and not the model in isolation.
Not DeepSeek-only
Same provider, different model, same outcome — which is why I don't think replayed reasoning_content alone explains it:
session ses_e805f62bdffeM5YPytW0TMxs6j, model opencode-go/glm-5.3 via a custom openai-compatible provider, 69 messages
an interrupted read tool part: status=error, error.type="unknown", no content (same shape as the shell part above, different error type)
from seq 510 on the turn never converges: 8 consecutive assistant turns announcing the same intent ("Ora eseguo davvero...", "Esecuzione reale, ora:", "Breaking the loop - executing now: ..."), finish: "tool-calls" each time, with a raw closing-delimiter fragment in the visible text: Breaking the loop - executing now: responseOra eseguo davvero: grep sulla documentazione API...
the last turn is finish: "error" with no tool parts, followed by idle outcome=interrupted
So on GLM the corruption shows up as a repetition loop plus a delimiter fragment in content (see also #55584), on DeepSeek as <end_of_thinking> / <tool_call>. Different marker, same shape: a turn interrupted mid-tool-call is never normalized, and everything after it degrades.
Why I think this is the same bug and not a separate one
The leak text (<|end▁of▁thinking|>, <tool_call>{...}) is the model continuing its own interrupted turn's template, exactly the imitation effect described here: what it imitates is the replayed CoT of a turn that ended mid-tool-call. The interrupt path adds a second defect: the aborted tool part is stored with no result at all, so the pair assistant(tool_call) / tool(result) is never closed — see also #50214, where MessageV2.toModelMessages is reported to treat incomplete tool parts as silently breaking model context, and #55096, where the same interrupt leads to a 400 instead.
Either one alone leaves the session able to re-enter the bad state; together they cover both the malformed pair and the imitation source.
Workaround
After interrupting a tool, abandon the session and start a new one. Continuing it always re-triggers the leak, because the interrupted turn stays in history.
Activity
opencode-agent commentedon Oct 17, 2026
Thanks for the A/B numbers, they're very helpful.
I checked the code for 2.0.30, and the request side matches what you describe. For any DeepSeek model (matched by provider, base URL or model ID), OpenCode sends
reasoning_contentback on every earlier assistant message, including turns before the latest user message. That's why all 480 assistant messages in your capture have it.I can't reproduce the leak itself here. It depends on how
deepseek-v4.1-flashresponds to that history, and testing that needs a real DeepSeek or OpenCode Go account, which this sandbox doesn't have. A maintainer with access will need to confirm it.One thing to consider for a fix: removing
reasoning_contententirely probably isn't safe. DeepSeek needs it passed back inside a tool-call turn, and requests without it are rejected (see #55096 and #55104). A narrower option would be to drop it only for assistant messages before the latest user message. If you still have your A/B setup, could you replay your captured request that way (keepreasoning_contentwithin the current turn, remove it from earlier turns) and share the leak rate? Please also say whether the API accepts that request. That would make the case much stronger.Related issues, most closely related first:
reasoning_text must be passed back400 #55584: other problems with replayed reasoning on deepseek-v4.1-flashreasoning_contentis missingqwop-os commentedon Oct 17, 2026
Tested the narrower variant as requested.
Setup: one captured request (1011 messages / 4.78 MB) replayed against the same endpoint
(
opencode.ai/zen/go/v1,deepseek-v4.1-flash), with a "write a ~120-line script" instructionappended. 5 runs per variant.
reasoning_contenteverywhere (baseline)"Leak" is defined strictly as: markup inside
delta.contentandtool_calls == 0andfinish_reason == "stop". (A naive substring count gives false positives, because the modellegitimately discusses those strings in
reasoning_content.)Two notes:
The API accepted both stripped variants - 0 errors. No 400 in our runs, including the variant
that strips
reasoning_contentfrom assistant messages that carrytool_calls. So on thisendpoint the "missing
reasoning_content-> 400" case (session: upstream rejects with missing 'reasoning_content' on older assistant message after model/provider switch #55096) did not trigger. It may bespecific to model/provider switches, as that issue's title suggests.
Narrowing to "current turn only" reduces the leak substantially (4/5 -> 1/5) but does not fully
remove it in our sample; stripping everywhere gave 0/5. If passing
reasoning_contentbackinside a tool-call turn is required, the narrow fix looks like a reasonable compromise - though
note the sample is small (n=5).
One caveat worth flagging: #50206 mentions the same markup-as-text symptom being reported with
other clients too. If that is accurate, replayed
reasoning_contentmay not be the only trigger.In our environment the A/B above is a clean controlled comparison (same request, same endpoint, same
model, only the
reasoning_contentfields differ), so it is at least a sufficient cause here.dvdred commentedon Oct 27, 2026
A deterministic trigger for this leak: interrupting a tool call, then continuing the same session.
This may be the reproduction case you're missing — you don't need to wait for a 1000-message session to hit it, and it doesn't depend on luck: interrupt a running
shelltool, send any follow-up message in the same session, and the very next assistant turn leaks markup.Environment
opencode acp@opencode/ai/providers/openai-compatible→ local OpenAI-compatible proxy,deepseek-v4.1-flashopencodeprovider (zen),deepseek-v4.1-flash-free— same leak, after a server restart mid-sessioncompatibility.reasoningField: "reasoning_content", so the reasoning channel is being parsed correctly; the stored parts in this session carrytype: "reasoning"withnative.reasoningField: "reasoning_content"Message sequence (from
session_message,~/.local/share/opencode/opencode.db)The aborted tool part, verbatim (
<repo>= project path):{ "type": "tool", "id": "call_7041d03d85fd48f5a81d4d87", "name": "shell", "executed": false, "state": { "status": "error", "input": { "command": "for i in $(seq 1 20); do\n used=$(ssh -o BatchMode=yes <host> 'nvidia-smi --query-gpu=memory.used --format=csv,noheader,nounits | sed -n \"6p\"' 2>/dev/null)\n echo \"t=$((i*15))s gpu5=${used}MiB\"\n if [ -n \"$used\" ] && [ \"$used\" -lt 2500 ]; then echo FREE; break; fi\n sleep 15\ndone", "timeout": 360000 }, "error": { "type": "aborted", "message": "Tool execution interrupted" }, "metadata": { "shellID": "sh_17f84d1ce0016qCKfSJbRL43Ld" } } }There is no
contentkey understate: the interrupted tool call is persisted without any result. So on the next request the history contains an assistant turn with a tool call and no matching tool result — on top of a replayed reasoning trace from the interrupted turn (which is thereasoning_content-replay condition this issue describes). Both of #55096's ingredients are present.Steps to reproduce
esc/ ACPsession/cancel).finish: "stop", no tool executed.How often
session_messagecontain a tool part with"type":"aborted"— the missing-result condition is common, not exotic.<end_of_thinking>/<tool_call>). Three follow an interruption (abortedshell, aborted tool +editerror, server restart mid-session); the fourth shows the degradation starting on its own:finish: "stop"with the model narrating a tool call instead of making one ("Let me read TASK-tmp.md and check the project state."), then after the user asks "why do you stop?" the next turns leak the terminator - same shape as Code Mode: model writes tool call markup as plain text — call is dropped and the step ends idle #57344/compaction: model emits tool calls as literal text after compaction — tools never run, session returns no output #50814. A GLM 5.3 session leaks a different marker - see below. So the interrupt is the reliable trigger, not the only one.finishvalues, no markup. Plugins disabled → identical result. So it is not a plugin, not the tool loop, and not the model in isolation.Not DeepSeek-only
Same provider, different model, same outcome — which is why I don't think replayed
reasoning_contentalone explains it:ses_e805f62bdffeM5YPytW0TMxs6j, modelopencode-go/glm-5.3via a customopenai-compatibleprovider, 69 messagesreadtool part:status=error,error.type="unknown", nocontent(same shape as theshellpart above, different error type)finish: "tool-calls"each time, with a raw closing-delimiter fragment in the visible text:Breaking the loop - executing now: responseOra eseguo davvero: grep sulla documentazione API...finish: "error"with no tool parts, followed byidle outcome=interruptedSo on GLM the corruption shows up as a repetition loop plus a delimiter fragment in
content(see also #55584), on DeepSeek as<end_of_thinking>/<tool_call>. Different marker, same shape: a turn interrupted mid-tool-call is never normalized, and everything after it degrades.Why I think this is the same bug and not a separate one
The leak text (
<|end▁of▁thinking|>,<tool_call>{...}) is the model continuing its own interrupted turn's template, exactly the imitation effect described here: what it imitates is the replayed CoT of a turn that ended mid-tool-call. The interrupt path adds a second defect: the aborted tool part is stored with no result at all, so the pairassistant(tool_call)/tool(result)is never closed — see also #50214, whereMessageV2.toModelMessagesis reported to treat incomplete tool parts as silently breaking model context, and #55096, where the same interrupt leads to a 400 instead.Suggested fix (combined)
aborted/error, persist a result body so the tool pair is always complete before the request is built ([FEATURE]: Sanitize interrupted running/pending tool parts on subagent resume #50214), andreasoning_contentfor assistant messages before the latest user message (Bug: reasoning_content is replayed in the request history, causing tool-call markup leakage #56352), keeping it only within the current turn as the maintainer bot suggested — that variant already measured 1/5 vs 4/5 in the A/B above.Either one alone leaves the session able to re-enter the bad state; together they cover both the malformed pair and the imitation source.
Workaround
After interrupting a tool, abandon the session and start a new one. Continuing it always re-triggers the leak, because the interrupted turn stays in history.