Skip to content

fix(core): read Claude instructions after plugin context hooks - #61273

Open
pjiang000-eng wants to merge 3 commits into
anomalyco:devfrom
pjiang000-eng:claude-system-order
Open

pjiang000-eng wants to merge 3 commits into
anomalyco:devfrom
pjiang000-eng:claude-system-order

Conversation

@pjiang000-eng

@pjiang000-eng pjiang000-eng commented Nov 2, 2026 •

Copy link
Copy Markdown

Issue for this PR

Closes #61248

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

The provider joins event.system into claudeCodeInstructions inside its own context hook. Built-in providers register before user plugins and hooks run in registration order, so a plugin that pushes a system part later never reaches it. The option is now an enumerable accessor over event.system, read when the request is built so later hooks are included, with a setter that collapses it back to a plain value so a plugin can still assign an override. The value stays a string; no consumer changes.

How did you verify your code works?

The new case in ClaudeAgentPlugin session hooks fails on dev (Received: "") and passes here. The repro from the issue passes. claude-agent tests 83 pass / 0 fail, bun run check clean. A second case pins the override path: without the setter, assigning the option throws TypeError: Attempted to assign to readonly property under ESM.

Visual

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 72" width="240" height="72" role="img" aria-label="A system line added by a plugin hook now reaches Claude's system prompt">
  <style>.t{font:9px ui-sans-serif,system-ui,sans-serif;fill:currentColor;fill-opacity:.45}</style>
  <rect x="4" y="10" width="86" height="52" rx="6" fill="none" stroke="currentColor" stroke-opacity=".35"/>
  <text class="t" x="12" y="24">plugin hook</text>
  <rect x="150" y="10" width="86" height="52" rx="6" fill="none" stroke="currentColor" stroke-opacity=".35"/>
  <text class="t" x="158" y="24">Claude system</text>
  <path d="M96 40h44" stroke="currentColor" stroke-opacity=".3" stroke-width="1.5" stroke-dasharray="4 4"/>
  <rect x="158" y="32" width="70" height="10" rx="3" fill="currentColor" fill-opacity=".16"/>
  <rect x="158" y="46" width="52" height="10" rx="3" fill="currentColor" fill-opacity=".16"/>
  <g fill="currentColor" fill-opacity=".85">
    <rect x="12" y="36" width="60" height="9" rx="3"/>
    <animateTransform attributeName="transform" type="translate" values="0 0;146 0;146 0" keyTimes="0;0.55;1" dur="2.6s" repeatCount="indefinite"/>
  </g>
</svg>

Screenshots / recordings

Not a UI change.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix works. I ran the issue's reproduction through the claude-agent plugin harness for context, generate and compaction. On the base, system text pushed by a later hook is missing from claudeCodeInstructions (""). With this change it is included for all three. On the real request path the getter is safe: every hook gets the same event object, and SessionModelRequest.prepare only reads shaped.options after all hooks have run. The claude-agent tests pass (82/82).

Public API: the plugin API keeps its shape. One plugin-visible change: claudeCodeInstructions on event.options is now a getter with no setter (see the inline comment).

event.options[ClaudeSystemPrompt.INSTRUCTIONS_OPTION] = instructions(event.system)
// Built-in providers register before user plugins, so `event.system` still grows after this hook
// returns. Read it when the request is built instead of folding it into a string here.
Object.defineProperty(event.options, ClaudeSystemPrompt.INSTRUCTIONS_OPTION, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This property has a getter but no setter. Before, a later plugin hook could set event.options.claudeCodeInstructions = "..." to override what Claude receives. Now that assignment throws TypeError: Attempted to assign to readonly property in strict-mode code (ES modules, so plugins), which fails the hook. Adding a setter would keep overrides working, for example one that redefines the property as a plain value: set: (value) => Object.defineProperty(event.options, key, { value, enumerable: true, configurable: true, writable: true }).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — added the setter, so a later hook assigning event.options.claudeCodeInstructions = "..." still wins; it redefines the key as a plain writable value. Covered by a new case in ClaudeAgentPlugin session hooks: without the setter that assignment throws TypeError: Attempted to assign to readonly property under ESM. claude-agent tests 83/83, bun run check clean.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only new commit merges dev and doesn't touch this change. The fix still works, and the claude-agent tests pass on the merged head (82/82). The earlier point about the getter having no setter is still open. Public API: no new changes.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The earlier point about the missing setter is fixed: a later plugin can assign claudeCodeInstructions again, and the new test covers it. The claude-agent tests pass (86/86), along with the issue's reproduction for context, generate and compaction. Nothing new to raise. Public API unchanged.

@Project579

Copy link
Copy Markdown

Thanks for this. Two things I noticed while working on the same bug:

  • instructions() still skips system[0] by position, so a plugin that unshifts a part gets it dropped, and OpenCode's own prompt is sent instead.
  • IdentityPlugin's # Your Model block (spliced at index 1) now reaches Claude too, next to Claude Code's own identity.

Tagging the base prompt and identity parts in their metadata and skipping by tag fixed both for me.

@pjiang000-eng

Copy link
Copy Markdown
Author

Both are right, and I checked:

  • instructions() does system.slice(1), so an unshifted part is the one dropped while OpenCode's own prompt is sent — positional assumption, same class as the bug in the issue.
  • ...ProviderPlugins sits at index 13 of the pre list in internal.ts and IdentityPlugin.Plugin at 19, so the identity splice(1, 0, ...) lands after the provider's hook. Deferring the read pulls # Your Model into the request, which is a behavior change beyond what Provider claude-agent: system text / prompts added by plugin context hooks never reach Claude #61248 asks for.

Tagging the base prompt and identity parts and skipping by tag is the right shape. Are you planning to open a PR for it? If not I'd rather fold it into this one than have two competing diffs on the same function — happy to take it either way.

@Project579

Copy link
Copy Markdown

Are you planning to open a PR for it? no, cause you are already working on it, I built a fix on top of yours, here it is if you wanna take a look at it: Project579@97ddcb9dcd

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants