Skip to content

Provider claude-agent: system messages in history are dropped, so keyed instruction updates never reach Claude #62916

Open

Description

@Blind-Striker

Description

On the claude-agent provider, system messages in the conversation history never reach Claude.
The keyed session instructions API delivers every update after the first request as exactly such a
message, so a plugin's later ctx.session.instructions.entry.put or remove never reaches Claude.

  • ctx.session.instructions.entry.put / remove (experimental): the first value goes into the
    request's system parts. Every later change is appended at the next step boundary as a stored
    system message, "Instructions updated: …"
    (packages/core/src/session/message-updater.ts:154, instruction-state.ts,
    runner/to-llm-message.ts).
  • The connector skips system messages, both when it converts the prompt for Claude and when it
    fingerprints history:
    • packages/core/src/plugin/provider/claude-agent/language-model/convert-to-claude-message.ts:13
    • packages/core/src/plugin/provider/claude-agent/sync/fingerprint.ts:91
    • packages/core/src/plugin/provider/claude-agent/sync/decide.ts:47

On other providers the update message is part of the request. On claude-agent the model keeps the
first value for the rest of the session.

This is related to #61248 / #61273 but not the same: those cover system parts that a plugin
pushes into event.system, which the provider snapshots too early. This one covers system-role
messages in history. #61273 does not touch the conversion above.

Expected: an instruction update reaches Claude without changing the system prompt (a change
there restarts the Claude process, per the provider spec). One option is to render it into the user
content, the way other notices are.

Found by reading the source at v2.2.5 (9bbb8ce); the lines above are unchanged on dev. It was
not reproduced against a live Claude account.

Plugins

A custom server plugin (any plugin that uses ctx.session.instructions.entry)

OpenCode version

2.2.5

Steps to reproduce

  1. In a server plugin, call ctx.session.instructions.entry.put({ sessionID, key: "repro.state", value: "A" }) before the session's first request.
  2. After the first step, call it again with "B".
  3. Run the session on claude-agent/<model> and on openai/<model>, and ask the model for the current value.
  4. Expected from the source: OpenAI sees the "Instructions updated" message with B; Claude still answers A.

Operating System

Windows 11

Activity

  1. opencodejr commented on Nov 9, 2026

    @opencodejr

    Thanks for the detailed report. I confirmed this at the connector level on v2.2.5, and it still happens on the latest dev (617a770ae). There's no Claude account in this sandbox, so I couldn't check what the model actually answers. What I could check is the content the connector sends to Claude.

    The script below loads the claude-agent connector's own modules. It gives them the second-turn prompt OpenCode builds after an instruction update: base system prompt, user, assistant, the system message "Instructions updated: repro.state … B", then a new user question.

    • Fresh or rebuilt Claude session: ConvertToClaudeMessage.content() returns only the user and assistant turns. The update is missing.
    • Continuing a live session: Fingerprint.groups() skips the system message, so SyncDecision.decide picks start: 4, which comes after the update. CatchUp.content() then sends Claude only the new question.

    Other providers get the update, because runner/to-llm-message.ts keeps it as a system message. The baseline system parts only hold the first value, so Claude keeps A.

    The update mechanism covers every instruction source, not just plugin entries. So changes to things like AGENTS.md or MCP and skill instructions during a session probably don't reach Claude on this provider either. I haven't tested those separately.

    Related: #61248 / #61273 (plugin system parts read too early) and #61551 (forks replaying superseded instructions).

    Reproduction script (repro.ts)
    // Reproduces #62916: the claude-agent connector drops system-role messages from history,
    // which is how OpenCode delivers "Instructions updated" notices after the first request.
    // Run from an OpenCode checkout: REPO=/path/to/opencode bun /workspace/repro/repro.ts
    const repo = process.env.REPO ?? "/workspace/repo"
    const base = `${repo}/packages/core/src/plugin/provider/claude-agent`
    const { ConvertToClaudeMessage } = await import(`${base}/language-model/convert-to-claude-message.ts`)
    const { Fingerprint } = await import(`${base}/sync/fingerprint.ts`)
    const { SyncDecision } = await import(`${base}/sync/decide.ts`)
    const { CatchUp } = await import(`${base}/sync/catch-up.ts`)
    
    // The prompt OpenCode sends on the second turn after a plugin changed key repro.state from A to B.
    // (runner/to-llm-message.ts maps the stored SessionMessage.System to a role "system" message.)
    const update = "Instructions updated: repro.state\nrepro.state = B"
    const prompt: any[] = [
      { role: "system", content: "base system prompt (repro.state = A)" },
      { role: "user", content: [{ type: "text", text: "remember the value" }] },
      { role: "assistant", content: [{ type: "text", text: "ok" }] },
      { role: "system", content: update },
      { role: "user", content: [{ type: "text", text: "what is repro.state now?" }] },
    ]
    
    const text = (blocks: any[]) => blocks.map((b) => (b.type === "text" ? b.text : `[${b.type}]`)).join("\n")
    
    console.log("== ConvertToClaudeMessage.content(full prompt) (fresh/rebuilt Claude session) ==")
    const fresh = text(ConvertToClaudeMessage.content(prompt))
    console.log(fresh)
    console.log("contains update:", fresh.includes("repro.state = B"))
    
    // Continuing a live Claude session whose record already has the first user+assistant turn.
    const groups = Fingerprint.groups(prompt.slice(0, 3))
    const record: any = { history: groups.map((g: any) => ({ hash: g.hash })) }
    const decision = SyncDecision.decide(prompt, record)
    console.log("\n== continuing Claude session ==")
    console.log("groups:", Fingerprint.groups(prompt).map((g: any) => `${g.role}[${g.start},${g.end})`).join(" "))
    console.log("decision:", JSON.stringify(decision))
    const cont = text(CatchUp.content(prompt, decision.start))
    console.log("content sent to Claude:\n" + cont)
    console.log("contains update:", cont.includes("repro.state = B"))

    Run it: bun repro.ts

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions