Skip to content

Provider claude-agent: system text / prompts added by plugin context hooks never reach Claude #61248

Open

Description

@maurits150

Description

System prompts added via plugin context hooks don't reach Claude.

The server plugin docs describe that this should work.

Additional context:

The provider copies the system parts in its own context hook (claude-agent/instructions.ts). But the provider hook runs before plugin hooks, so Claude does not get later changes to event.system. Basically claude-agent seems to store the system instructions in a string but that string no longer updates during runtime when plugins run and amend their lines.

Plugins

Reproducable without plugins

OpenCode version

2.2.5

Steps to reproduce

  1. Add this plugin as .opencode/plugins/repro.js:

    export default {
      id: "repro.system-part",
      async setup(ctx) {
        await ctx.session.hook("context", (event) => {
          event.system.push({ type: "text", text: "The project codename is ZEBRA-4417." })
        })
      },
    }
  2. Run:

    opencode run --standalone -m claude-agent/sonnet "What is the project codename? Reply with only the codename, or UNKNOWN if you were not told one."
    opencode run --standalone -m openai/gpt-6-sol "What is the project codename? Reply with only the codename, or UNKNOWN if you were not told one."

Expected Behavior

Both models answer ZEBRA-4417.

Actual Behavior

  • claude-agent/sonnet and claude-agent/haiku answer UNKNOWN.
  • openai/gpt-6-sol and openai/gpt-6-luna answer ZEBRA-4417.

Screenshot

No response

Operating System

Ubuntu 26.04 LTS

Terminal

Konsole (KDE)

Activity

  1. opencodejr commented on Nov 2, 2026

    @opencodejr

    Thanks for the clear report and the analysis. I could confirm this on the latest dev (67139d6).

    There was no real Claude Code account available here, so I tested with the repo's claude-agent plugin test harness and a fake Claude:

    1. Load the built-in claude-agent provider plugin.
    2. Register a second context hook, like your plugin, that pushes The project codename is ZEBRA-4417. into event.system.
    3. Fire the context hook for a claude-agent model.

    After the hooks run, event.system does contain the codename, but the instructions passed to Claude Code are an empty string. As you said, the provider's own context hook (claude-agent/instructions.ts) turns event.system into a string when it runs. Hooks run in the order they were registered, so system parts added by plugins that run after it never reach Claude. The code involved hasn't changed since 2.2.5.

    The test below reproduces it. Place it in packages/core/test/plugin/provider/claude-agent/ and run it with bun test from packages/core.

    Reproduction script (issue-61248.test.ts)
    import { Message, SystemPart } from "@opencode/ai/effect"
    import type { LanguageModelV3 } from "@ai-sdk/provider"
    import type { AccountInfo } from "@anthropic-ai/claude-agent-sdk"
    import { AISDK } from "@opencode/core/aisdk"
    import { Agent } from "@opencode/core/agent"
    import { Bus } from "@opencode/core/bus"
    import type { ClaudeIO } from "@opencode/core/plugin/provider/claude-agent/claude/io"
    import { ClaudeSessions } from "@opencode/core/plugin/provider/claude-agent/claude/sessions"
    import { RequestKind } from "@opencode/core/plugin/provider/claude-agent/language-model/request-kind"
    import { ClaudeSystemPrompt } from "@opencode/core/plugin/provider/claude-agent/language-model/system-prompt"
    import { ClaudeIntegration } from "@opencode/core/plugin/provider/claude-agent/integration"
    import { ClaudeProvider } from "@opencode/core/plugin/provider/claude-agent/provider"
    import { Credential } from "@opencode/core/credential"
    import { Database } from "@opencode/core/database/database"
    import { Environment, makeFiles } from "@opencode/core/environment/index"
    import { EnvironmentUnavailable } from "@opencode/core/environment/unavailable"
    import { Integration } from "@opencode/core/integration"
    import { KV } from "@opencode/core/kv"
    import { Location } from "@opencode/core/location"
    import { Model } from "@opencode/core/model"
    import { Money } from "@opencode/schema/money"
    import { Plugin } from "@opencode/core/plugin"
    import { PluginHooks } from "@opencode/core/plugin/hooks"
    import { PluginHost } from "@opencode/core/plugin/host"
    import { ID, make } from "@opencode/core/plugin/provider/claude-agent/index"
    import { Provider } from "@opencode/core/provider"
    import { Session } from "@opencode/core/session"
    import { SessionInbox } from "@opencode/core/session/inbox"
    import { describe, expect } from "bun:test"
    import { Effect } from "effect"
    import { FakeClaude } from "./lib/fake-claude"
    import { FakeIO } from "./lib/fake-io"
    import { TestSessions } from "./lib/sessions"
    import { eventually } from "../../../lib/eventually"
    import { testEffect } from "../../../lib/effect"
    import { PluginTestLayer } from "../../fixture"
    
    const it = testEffect(PluginTestLayer)
    const integrationID = Integration.ID.make(ClaudeProvider.ID)
    const haiku = Model.Ref.make({ providerID: ClaudeProvider.ID, id: Model.ID.make("haiku") })
    
    // The registry outlives the plugin's scope in opencode, so each test closes its own, as an exiting opencode would.
    const addPlugin = Effect.fn(function* (io: ClaudeIO.Interface) {
      const registry = ClaudeSessions.registry()
      yield* Effect.addFinalizer(() => registry.close())
      const host = yield* PluginHost.make(yield* Plugin.Service, ID)
      yield* make(io, registry).effect(host)
      return registry
    })
    
    const storage = Effect.gen(function* () {
      return PluginHost.storage(yield* KV.Service, ID)
    })
    
    const connect = Effect.gen(function* () {
      const credentials = yield* Credential.Service
      return yield* credentials.create({
        integrationID,
        value: Credential.External.make({
          type: "external",
          methodID: Integration.MethodID.make(ClaudeIntegration.METHOD_ID),
        }),
      })
    })
    
    const claudeModels = Effect.gen(function* () {
      const result: Model.Info[] = []
      const models = yield* Model.Service
      for (const model of yield* models.all()) {
        if (model.providerID === ClaudeProvider.ID) result.push(model)
      }
      return result
    })
    
    const connectionStatus = Effect.gen(function* () {
      const integrations = yield* Integration.Service
      const integration = yield* integrations.get(integrationID)
      return integration?.connections[0]?.status
    })
    
    function find(models: readonly Model.Info[], id: string) {
      const model = models.find((model) => model.id === id)
      if (!model) throw new Error(`Expected model ${id}`)
      return model
    }
    
    const price = [
      {
        input: Money.USDPerMillionTokens.make(3),
        output: Money.USDPerMillionTokens.make(15),
        cache: { read: Money.USDPerMillionTokens.make(0.3), write: Money.USDPerMillionTokens.make(3.75) },
      },
    ]
    
    const apiKey: AccountInfo = { apiKeySource: "ANTHROPIC_API_KEY", apiProvider: "firstParty" }
    
    // Stands in for models.dev, which lists prices before the plugin runs
    const listPrice = Effect.fn(function* (providerID: Provider.ID, modelID: string) {
      const providers = yield* Provider.Service
      yield* providers.transform((editor) => {
        editor.add({
          info: Provider.Info.empty(providerID),
          models: [{ ...Model.Info.default(providerID, Model.ID.make(modelID)), cost: price }],
        })
      })
    })
    
    const sonnetCost = Effect.fn(function* (account: AccountInfo, env?: Record<string, string>) {
      const onConnect = FakeClaude.expectStartup({ account })
      const claude = FakeClaude.make([onConnect])
      yield* addPlugin(FakeIO.make({ claude, env }))
      yield* connect
      yield* Effect.promise(() => claude.reached(onConnect))
      const models = yield* eventually(claudeModels, (models) => models.length > 0)
      claude.check()
      return find(models, "sonnet").cost
    })
    
    
    describe("issue 61248", () => {
      it.live("system parts pushed by a later plugin context hook reach Claude's instructions", () =>
        Effect.gen(function* () {
          yield* addPlugin(FakeIO.make({ claude: FakeClaude.make([]) }))
          const agents = yield* Agent.Service
          yield* agents.transform((editor) => {
            editor.update(Agent.ID.make("build"), () => {})
          })
          const hooks = yield* PluginHooks.Service
          // A user plugin, loaded after the built-in claude-agent provider, as in the report.
          yield* hooks.register("session", "context", (event) =>
            Effect.sync(() => {
              event.system.push(SystemPart.make("The project codename is ZEBRA-4417."))
            }),
          )
          const location = yield* Location.Service
          const sessions = yield* Session.Service
          const session = yield* sessions.create({ location: { directory: location.directory } })
          const context = yield* hooks.trigger("session", "context", {
            sessionID: session.id,
            agent: Agent.ID.make("build"),
            model: haiku,
            system: [SystemPart.make("OPENCODE-PROMPT")] as SystemPart[],
            messages: [] as Message[],
            options: {} as Record<string, unknown>,
            tools: {},
          })
          console.log("event.system:", JSON.stringify(context.system.map((p) => p.text)))
          console.log("instructions sent to Claude:", JSON.stringify(context.options[ClaudeSystemPrompt.INSTRUCTIONS_OPTION]))
          expect(context.system.map((p) => p.text)).toContain("The project codename is ZEBRA-4417.")
          expect(String(context.options[ClaudeSystemPrompt.INSTRUCTIONS_OPTION])).toContain("ZEBRA-4417")
        }),
      )
    })

    Run it: bun issue-61248.test.ts

  2. pjiang000-eng commented on Nov 2, 2026

    @pjiang000-eng

    I'd like to take this. Root cause is the provider snapshotting event.system in its own context hook, which runs before user plugins register. Reproduced as a failing test; fix reads the system parts when the request is built. PR coming.

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