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
Provider claude-agent: system text / prompts added by plugin context hooks never reach Claude #61248
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
Add this plugin as .opencode/plugins/repro.js:
exportdefault{id: "repro.system-part",asyncsetup(ctx){awaitctx.session.hook("context",(event)=>{event.system.push({type: "text",text: "The project codename is ZEBRA-4417."})})},}
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.
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:
Load the built-in claude-agent provider plugin.
Register a second context hook, like your plugin, that pushes The project codename is ZEBRA-4417. into event.system.
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"importtype{LanguageModelV3}from"@ai-sdk/provider"importtype{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"importtype{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"constit=testEffect(PluginTestLayer)constintegrationID=Integration.ID.make(ClaudeProvider.ID)consthaiku=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.constaddPlugin=Effect.fn(function*(io: ClaudeIO.Interface){constregistry=ClaudeSessions.registry()yield*Effect.addFinalizer(()=>registry.close())consthost=yield*PluginHost.make(yield*Plugin.Service,ID)yield*make(io,registry).effect(host)returnregistry})conststorage=Effect.gen(function*(){returnPluginHost.storage(yield*KV.Service,ID)})constconnect=Effect.gen(function*(){constcredentials=yield*Credential.Servicereturnyield*credentials.create({
integrationID,value: Credential.External.make({type: "external",methodID: Integration.MethodID.make(ClaudeIntegration.METHOD_ID),}),})})constclaudeModels=Effect.gen(function*(){constresult: Model.Info[]=[]constmodels=yield*Model.Servicefor(constmodelofyield*models.all()){if(model.providerID===ClaudeProvider.ID)result.push(model)}returnresult})constconnectionStatus=Effect.gen(function*(){constintegrations=yield*Integration.Serviceconstintegration=yield*integrations.get(integrationID)returnintegration?.connections[0]?.status})functionfind(models: readonlyModel.Info[],id: string){constmodel=models.find((model)=>model.id===id)if(!model)thrownewError(`Expected model ${id}`)returnmodel}constprice=[{input: Money.USDPerMillionTokens.make(3),output: Money.USDPerMillionTokens.make(15),cache: {read: Money.USDPerMillionTokens.make(0.3),write: Money.USDPerMillionTokens.make(3.75)},},]constapiKey: AccountInfo={apiKeySource: "ANTHROPIC_API_KEY",apiProvider: "firstParty"}// Stands in for models.dev, which lists prices before the plugin runsconstlistPrice=Effect.fn(function*(providerID: Provider.ID,modelID: string){constproviders=yield*Provider.Serviceyield*providers.transform((editor)=>{editor.add({info: Provider.Info.empty(providerID),models: [{ ...Model.Info.default(providerID,Model.ID.make(modelID)),cost: price}],})})})constsonnetCost=Effect.fn(function*(account: AccountInfo,env?: Record<string,string>){constonConnect=FakeClaude.expectStartup({ account })constclaude=FakeClaude.make([onConnect])yield*addPlugin(FakeIO.make({ claude, env }))yield*connectyield*Effect.promise(()=>claude.reached(onConnect))constmodels=yield*eventually(claudeModels,(models)=>models.length>0)claude.check()returnfind(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([])}))constagents=yield*Agent.Serviceyield*agents.transform((editor)=>{editor.update(Agent.ID.make("build"),()=>{})})consthooks=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."))}),)constlocation=yield*Location.Serviceconstsessions=yield*Session.Serviceconstsession=yield*sessions.create({location: {directory: location.directory}})constcontext=yield*hooks.trigger("session","context",{sessionID: session.id,agent: Agent.ID.make("build"),model: haiku,system: [SystemPart.make("OPENCODE-PROMPT")]asSystemPart[],messages: []asMessage[],options: {}asRecord<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")}),)})
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.
Activity
opencodejr commentedon Nov 2, 2026
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:
claude-agentprovider plugin.contexthook, like your plugin, that pushesThe project codename is ZEBRA-4417.intoevent.system.contexthook for aclaude-agentmodel.After the hooks run,
event.systemdoes contain the codename, but the instructions passed to Claude Code are an empty string. As you said, the provider's owncontexthook (claude-agent/instructions.ts) turnsevent.systeminto 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 withbun testfrompackages/core.Reproduction script (issue-61248.test.ts)
Run it:
bun issue-61248.test.tspjiang000-eng commentedon Nov 2, 2026
I'd like to take this. Root cause is the provider snapshotting
event.systemin its owncontexthook, which runs before user plugins register. Reproduced as a failing test; fix reads the system parts when the request is built. PR coming.