Skip to content

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

Closed

Description

@thystips

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

  1. 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).
  2. Let the assistant finish a step that ends with a tool output — the assistant message content types are reasoning,text,tool with finish: "tool-calls".
  3. 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.md of 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/date change on a new day also fires.
  4. 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

  1. opencode-agent commented on Oct 14, 2026

    @opencode-agent
    Contributor

    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 developer item directly after the function_call_output item in the Responses input. 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)
    // Reproduction for anomalyco/opencode#55389: a mid-session system update stored right
    // after a tool output is lowered as a `developer` item directly after `function_call_output`.
    import { describe, expect } from "bun:test"
    import { Effect } from "effect"
    import { LLM, Message, ToolCallPart, ToolResultPart } from "../src/index.js"
    import { Auth } from "../src/route.js"
    import { compileRequest } from "../src/route/client.js"
    import * as OpenAIResponses from "../src/protocols/openai-responses.js"
    
    const model = OpenAIResponses.route
      .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
      .model({ id: "gpt-4.1-mini" })
    
    import { it } from "./lib/effect.js"
    
    describe("#55389", () => {
      it.effect("system update after a tool output", () =>
        Effect.gen(function* () {
          const prepared = yield* compileRequest(
            LLM.request({
              model,
              system: "You are concise.",
              tools: [{ name: "lookup", description: "Look up", inputSchema: { type: "object", properties: {} } }],
              messages: [
                Message.user("Look it up."),
                Message.assistant(ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })),
                Message.tool(ToolResultPart.make({ id: "call_1", name: "lookup", result: "42", resultType: "text" })),
                Message.system("The available skills have changed."),
              ],
            }),
          )
          const input = (prepared.body as any).input as any[]
          console.log(JSON.stringify(input.map((i) => ({ type: i.type, role: i.role })), null, 0))
          const idx = input.findIndex((i) => i.type === "function_call_output")
          // Responses endpoints reject a developer/system item directly after a tool output.
          expect(input[idx + 1]?.role).not.toBe("developer")
        }),
      )
    })

    Run it: bun repro-55389.test.ts

  2. thystips commented on Oct 14, 2026

    @thystips
    Author

    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:

    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
    

    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:

    For anyone on the latest channel 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, /compact and model switching all replay the same history, and session.revert offers no boundary at a system notice.

    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.

  3. thystips commented on Oct 14, 2026

    @thystips
    Author

    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 fresh core/codemode / core/mcp-guidance notice. 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:

    445  assistant  finish=error -> 400                                   (previous occurrence)
    453  system     sources=[core/codemode, core/mcp-guidance]            "The catalog is partial"
    454  user       "retry please"
    455  assistant  finish=error -> 400
    

    Worth noting: in the raw transcript this notice is not adjacent to the tool output — there are idle and empty error-assistant messages in between (447, 449). They don't prevent the function_call_output → developer sequence 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.

  4. thystips commented on Oct 14, 2026

    @thystips
    Author

    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:

    √ execute
    > opencode.session_move [directory=<repo>.worktrees/<name>]
      Moved to <repo>.worktrees/<name>
      Instructions updated: core/codemode, core/mcp-guidance, core/instructions, core/environment
    Error: A tool output cannot be combined with new developer or system context.
    

    The Code Mode execute call is the tool output; the directory change immediately forces a context re-read, and the Instructions updated notice is lowered right after function_call_output → 400 in the same step. No external edit involved — the model just moved its own session.

    Transcript:

    11434  assistant  [tool]                 finish=tool-calls
    11443  location-switched                (directory -> <repo>.worktrees/<name>)
    11445  assistant  finish=error -> 400
    11453  system     [core/codemode, core/mcp-guidance]
    11454  user       "retry please"
    11455  assistant  finish=error -> 400
    

    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_seq is 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.)

  5. rekram1-node commented on Oct 14, 2026

    @rekram1-node
    Collaborator

    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:

    • OpenRouter (https://openrouter.ai/api/v1/responses): deepseek/deepseek-v4.1-flash (tested both with default routing and pinned to each of its 28 active upstream providers, including deepseek direct), moonshotai/kimi-k3, openai/gpt-4.1-mini, openai/gpt-5.4-nano, openai/gpt-5.5, anthropic/claude-haiku-4.5, google/gemini-2.5-flash, x-ai/grok-4.3, qwen/qwen3.8-max
    • Moonshot (https://api.moonshot.ai/v1/responses): kimi-k3, kimi-k2.6
    • DeepSeek (https://api.deepseek.com/v1/responses): deepseek-chat, deepseek-reasoner
    • OpenAI (https://api.openai.com/v1/responses, HTTP and WebSocket previous_response_id): 25 models across gpt-4o, gpt-4.1, o3/o4-mini, gpt-5.x, and gpt-6.x
    • xAI, Meta, DigitalOcean, Vercel AI Gateway, Alibaba, MiniMax, Azure, Fireworks, Baseten, Novita, Groq, DeepInfra, Venice, Xiaomi
      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:

    1. OpenCode's built-in openrouter provider uses /chat/completions, not /responses, unless overridden via a custom provider config / baseURL.
    2. OpenRouter's native /api/v1/responses error envelope is {"error":{"message":"...","code":400},"user_id":"..."} (numeric code: 400, no type: "invalid_request_error" or param: "input").

    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.)?

  6. self-assigned this
    on Oct 14, 2026
  7. thystips commented on Oct 15, 2026

    @thystips
    Author

    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:

    • our client config has no openrouter baseURL override anywhere, yet the same error envelope shows up on openrouter/* sessions too — which only makes sense if that traffic is intercepted as well, and is consistent with the envelope argument (missing user_id, plus the type/param fields OpenRouter doesn't emit);
    • the second error we hit on the same endpoint — 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_output and a following developer/system item, 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions