Repository navigation
core: mid-session context notice injected after a tool output wedges Responses-API sessions (400 "A tool output cannot be combined with new developer or system context") #55389
Description
Summary
OpenCode V2 injects a system message into a session's history when an instruction or tool source changes mid-session (this is by design: instruction changes are appended as chronological updates instead of rewriting the cached prefix). When that notice lands immediately after an assistant message that ends with a tool output, an OpenAI-compatible Responses endpoint rejects the whole request with:
400 {"error":{"message":"A tool output cannot be combined with new developer or system context.","type":"invalid_request_error","param":"input","code":"invalid_request_error"}}
The notice is persisted in history, so every later turn replays the same invalid sequence and the session becomes permanently unusable — retrying, resuming, /compact and switching models all fail identically. It also reproduces with no user action at all (server restart / opencode auto-update).
Environment
- opencode version: 2.0.29 (also observed on 2.0.28)
- OS: Linux 7.2.9-zen1-1-zen (x86_64)
- Terminal:
TERM=xterm-256color,COLORTERM=truecolor(kitty) - Shell:
/usr/bin/zsh - Install/channel: latest (auto-update)
- Active plugins: 3 local plugins (a Slack bridge, a project YAML-lint hook, a small local hook). The notices in question come from core (
core/instructions,core/skill-guidance,core/codemode,core/mcp-guidance,core/environment,core/date), not from a plugin — disabling plugins doesn't affect it.
Reproduction
- Use a session whose model is served by an OpenAI-compatible Responses endpoint (
param: "input"in the error confirms the Responses wire). Observed on OpenRouter/api/v1/responses(deepseek/deepseek-v4.1-flash) and on an internal Responses gateway (kimi models). - Let the assistant finish a step that ends with a tool output — the assistant message content types are
reasoning,text,toolwithfinish: "tool-calls". - Cause any instruction/tool-source change so the notice is injected right after that step. Deterministic triggers observed (any one is enough):
- edit a skill file of the project (
core/skill-guidance) → notice"The available skills have changed." - edit an
AGENTS.mdof the project or of another known repo/worktree (core/instructions) → notice"The instructions changed:"/"New instructions apply from:" - change MCP / tool catalog (
core/codemode,core/mcp-guidance) → notice"The Code Mode tool catalog has changed." - do nothing: just let the background service restart or opencode auto-update (observed v2.0.28 → v2.0.29). The restart re-reads the catalog/skills and a
core/datechange on a new day also fires.
- edit a skill file of the project (
- Send anything, or just let the pending step continue. The request fails with the 400 above, and every subsequent turn fails identically.
Expected Behavior
The mid-session context notice should be placed at a valid boundary for the provider so the request stays well-formed — e.g. held and released together just before the next assistant turn or after the next user message — which is exactly the class of placement rule PR #52568 implemented for Anthropic ("place Anthropic system updates before the next assistant turn"). Concretely: never leave a context-update system item directly adjacent after a tool output on the Responses protocol.
Actual Behavior
Every request from that session returns HTTP 400 before any model output; the provider body is stored as provider.invalid-request on the assistant message and the session outcome is failed. Re-sending, resuming, changing model, and manual compact all replay the same malformed history and fail.
Additional Context
Triggering sequences (read from the SQLite session transcript)
Skills change: tool output → system notice → 400
649 assistant content=[tool] finish=tool-calls
656 system sources=[core/skill-guidance] "The available skills have changed."
657 assistant finish=error -> 400
No user action at all — service restart / auto-update:
9293 synthetic "The server restarted while you were working."
9295 system sources=[core/codemode, core/mcp-guidance]
9296 assistant reasoning,text,tool finish=tool-calls
9307 system sources=[core/codemode, core/mcp-guidance]
9308 assistant finish=error -> 400
Location switch + several sources at once:
1338 location-switched
1339 system sources=[core/codemode, core/mcp-guidance, core/skill-guidance, core/instructions, core/environment]
1340 assistant reasoning,text,tool finish=tool-calls
1351 system sources=[core/codemode, core/mcp-guidance]
1352 assistant finish=error -> 400
Frequency / scope
18 occurrences across 9 sessions over 3 days (2026-10-12 → 2026-10-14), consistent (not a one-off). Model routes: weytop/kimi-k3, weytop/kimi-for-coding (an internal gateway) and openrouter/deepseek-v4.1-flash. The error body is byte-identical on both gateways, which points at the Responses item-ordering rule rather than a single provider's quirk. It also affects subagent sessions (an agent named *-reviewer was hit).
Workaround
The notice rows live only in session_message (type system, with metadata.notice) and have no matching part, so deleting them recovers the session:
DELETE FROM session_message
WHERE type='system'
AND coalesce(json_extract(data,'$.text'),'') NOT LIKE 'Instructions from:%';session.revert does not help: there is no revert boundary offered at a system notice, and forking/retrying reuses the same malformed history.
Duplicate check
Closest existing reports, none covering this: #53109 (tail truncation produces the same [tool, system, user]/stuck-on-400 symptom but a different root — compaction tail boundary), #52463 (session stuck on 400, trigger unknown), #36483 (defer self-authored AGENTS.md updates until the next turn), #53571 (empty assistant messages wedge strict providers), #52628 / #52452 / #44852 / #54588 (session-unrecoverable family), and #20290 / #20289 (re-announce tools after compaction — origin of the tool-catalog notices). PR #52568 fixed the same mechanism's placement for Anthropic but not for the Responses protocol.
Activity
opencode-agent commentedon Oct 14, 2026
Thanks for the detailed report. I reproduced this on the latest
dev(34d8ded0c) by testing how OpenCode builds the request. The code that builds Responses requests hasn't changed since v2.0.29.With a history of user → assistant tool call → tool result → a mid-session notice ("The available skills have changed."), OpenCode puts a
developeritem directly after thefunction_call_outputitem in the Responsesinput. That's the sequence your 400 rejects. Nothing moves or wraps the notice for the Responses protocol, as #52568 now does for Anthropic. The test below reproduces it; it fails on that ordering.There were no provider credentials here, so I couldn't trigger the 400 itself; your two gateways returning the same error body back it up. I've passed this on to the maintainers.
Related: #53109 (same stuck-on-400 symptom, caused by where compaction cuts the history tail), #51128 (a plugin reload mid-turn marks every skill as removed, which can trigger this notice), #52463 (session stuck on 400, trigger unknown).
Reproduction script (repro-55389.test.ts)
Run it:
bun repro-55389.test.tsthystips commentedon Oct 14, 2026
Adding a case the repro above doesn't cover, which I think raises the severity: this fires with no user action at all.
I lost a session purely to an opencode auto-update (v2.0.28 → v2.0.29). The background service restarted, re-read the tool catalog, and injected the notices into a session whose last step ended in a tool output; the next drain 400'd. Nothing was edited by hand:
So the trigger is not "the user edited a watched file mid-session" — that was my initial theory and it's incomplete. Any reload of a watched source while the last message is a tool output does it:
core/codemode,core/mcp-guidance)core/date)core/mcp-guidance)core/skill-guidancenoticeFor anyone on the
latestchannel with auto-update, an update landing while a session is open is enough to permanently wedge it, with no user error involved. And because the bad sequence is persisted, the session is unrecoverable in place: retry, resume,/compactand model switching all replay the same history, andsession.revertoffers no boundary at asystemnotice.That's why I'd read the placement rule as more than cosmetic — it's a data-loss-shaped failure triggered by a routine update. The Anthropic counterpart (#52568) already handles this class; the Responses lowering is the gap.
thystips commentedon Oct 14, 2026
One more observation from live triage, which explains why this recurs with no user edit at all: the notice is re-emitted automatically while MCP servers are still connecting.
The Code Mode catalog is delivered as
"The catalog is partial"and then updated every time another MCP server registers. Each update injects a freshcore/codemode/core/mcp-guidancenotice. On an MCP-heavy setup (~9 servers) that can be several notices per minute during startup/reload.I deleted the notice and recovered a session, and it re-poisoned itself ~3 minutes later:
Worth noting: in the raw transcript this notice is not adjacent to the tool output — there are
idleand empty error-assistant messages in between (447, 449). They don't prevent thefunction_call_output→developersequence from reforming once the notice is lowered in, which matches the lowering you reproduced.So the trigger surface is wider than "user edited a watched file": any watcher / MCP / restart activity that appends a notice can wedge a session that happens to be sitting on a tool output. Together with the auto-update case above, that's a steady supply of unrecoverable sessions without any user error.
thystips commentedon Oct 14, 2026
One more deterministic trigger, and the cleanest one: the agent wedged its own session by calling
session_move.From the TUI of a live session at the time:
The Code Mode
executecall is the tool output; the directory change immediately forces a context re-read, and theInstructions updatednotice is lowered right afterfunction_call_output→ 400 in the same step. No external edit involved — the model just moved its own session.Transcript:
So
session_move— and more generally anything that changes the session location — reproduces it back to back: tool output, then the context notice.One recovery detail that may help the fix:
instruction_state.through_seqis advanced to the notice's seq even when the drain fails, so the notice is not re-emitted on the next attempt. What keeps the history invalid is the notice row already written; deleting it recovers the session until the next real source change. (And if the last completed assistant step is a tool output — which is exactly the case here, because the moved turn never got an assistant reply — the session stays fragile until it reaches a text-producing turn.)rekram1-node commentedon Oct 14, 2026
Unable to reproduce this issue.
We ran a live probe of this exact item sequence ([user, ...assistant_tool_turn, function_call_output, { type: "message", role: "developer", content: "..." }], as well as function_call_output → developer → user and multi-step tool chains) across 21 /responses endpoints and 64 models, including:
All 64 returned 200 OK when a developer message immediately followed function_call_output.
Two details point to a proxy/gateway in front of both routes producing that error rather than OpenRouter or the Responses spec itself:
Could you share the provider configuration block from your opencode.json for openrouter and weytop, and what gateway/proxy software weytop is running (e.g. NewAPI, LiteLLM, a custom Responses-to-Chat translator, etc.)?
thystips commentedon Oct 15, 2026
Thanks @rekram1-node — that probe is exactly what was needed, and your read is right.
We can confirm the gateway is ours and custom:
<internal-gateway-host>is an internal OpenAI/Responses-compatible proxying layer, not a public provider. Your two tells match what we see locally:openrouterbaseURL override anywhere, yet the same error envelope shows up onopenrouter/*sessions too — which only makes sense if that traffic is intercepted as well, and is consistent with the envelope argument (missinguser_id, plus thetype/paramfields OpenRouter doesn't emit);code: "pending_call_history_mismatch","The pending tool call does not match its confirmed history"(HTTP 409) — is a bespoke code no public endpoint returns.So this is ours to fix: the gateway is enforcing strict adjacency between
function_call_outputand a followingdeveloper/systemitem, where the spec and the 21 endpoints / 64 models you probed tolerate it. We're taking it to the team that owns the gateway.Given that, feel free to close this as not-an-OpenCode bug — or keep it open if you'd still consider the notice placement worth hardening for strict Responses endpoints. PR #55445 (community contribution) targets exactly that, and your call on it stands either way.
Thanks again for the depth of the investigation — the 64-model matrix is what let us localise this.