Skip to content

Commit 97ddcb9

Browse files
Project579claude
andcommitted
fix(core): skip Claude-supplied system parts by tag
Claude Code writes its own base prompt and model identity. Tag OpenCode's versions and skip them by tag instead of dropping the first system part, so parts a plugin adds at the front still reach Claude and the identity block is not repeated. Empty parts are dropped. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MTg66vrTtvfX9f4eFX5AEp
1 parent 08edb90 commit 97ddcb9

4 files changed

Lines changed: 55 additions & 25 deletions

File tree

‎packages/core/src/plugin/identity.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@ export const Plugin = define({
2222
const hook = (event: SessionHooks["context"]) =>
2323
Effect.gen(function* () {
2424
const model = yield* OptimizePlugin.resolveModel(ctx, event)
25-
event.system.splice(1, 0, SystemPart.make(identity({ name: model.name, ref: event.model })))
25+
event.system.splice(1, 0, {
26+
...SystemPart.make(identity({ name: model.name, ref: event.model })),
27+
metadata: { experimental: { key: "core/identity" } },
28+
})
2629
}).pipe(Effect.catch(() => Effect.void))
2730
yield* ctx.session.hook("context", hook)
2831
yield* ctx.session.hook("compaction", hook)

‎packages/core/src/plugin/provider/claude-agent/instructions.ts‎

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@ import { ClaudeProvider } from "./provider.js"
88

99
const KINDS = ["context", "generate", "compaction"] as const
1010

11-
const InstructionsMetadata = Schema.Struct({
11+
const PartMetadata = Schema.Struct({
1212
experimental: Schema.Struct({
13-
instructions: Schema.Array(Schema.Struct({ key: Schema.String, text: Schema.String })),
13+
key: Schema.optional(Schema.String),
14+
instructions: Schema.optional(Schema.Array(Schema.Struct({ key: Schema.String, text: Schema.String }))),
1415
}),
1516
})
1617

17-
// Claude Code writes its own date and environment, so OpenCode's would repeat them.
18-
const SUPPLIED_BY_CLAUDE = new Set(["core/date", "core/environment"])
18+
// Claude Code writes its own base prompt, model identity, date and environment, so OpenCode's would repeat them.
19+
const SUPPLIED_BY_CLAUDE = new Set(["core/prompt", "core/identity", "core/date", "core/environment"])
1920

2021
export const register = Effect.fn("ClaudeInstructions.register")(function* (ctx: Context) {
2122
yield* Effect.forEach(
@@ -51,19 +52,15 @@ export const register = Effect.fn("ClaudeInstructions.register")(function* (ctx:
5152
)
5253
})
5354

54-
// The first part is OpenCode's own prompt, which Claude Code's prompt replaces.
55+
// OpenCode tags the parts Claude Code supplies itself, so plugins may add parts anywhere in the array.
5556
function instructions(system: ReadonlyArray<SystemPart>) {
56-
const texts: string[] = []
57-
for (const part of system.slice(1)) {
58-
const metadata = Schema.decodeUnknownOption(InstructionsMetadata)(part.metadata)
59-
if (Option.isNone(metadata)) {
60-
texts.push(part.text)
61-
continue
62-
}
63-
for (const source of metadata.value.experimental.instructions) {
64-
if (SUPPLIED_BY_CLAUDE.has(source.key)) continue
65-
texts.push(source.text)
66-
}
67-
}
68-
return texts.join("\n\n")
57+
return system
58+
.flatMap((part) => {
59+
const metadata = Option.getOrUndefined(Schema.decodeUnknownOption(PartMetadata)(part.metadata))?.experimental
60+
if (metadata?.key && SUPPLIED_BY_CLAUDE.has(metadata.key)) return []
61+
if (!metadata?.instructions) return [part.text]
62+
return metadata.instructions.filter((source) => !SUPPLIED_BY_CLAUDE.has(source.key)).map((source) => source.text)
63+
})
64+
.filter(Boolean)
65+
.join("\n\n")
6966
}

‎packages/core/src/session/model-request.ts‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,15 @@ export const baseTranscript = (input: {
109109
return {
110110
providerMetadataKey,
111111
system: [
112-
SystemPart.make(
113-
input.agent.system
114-
? input.agent.system
115-
: SessionSystemPrompt.make(input.tools.definitions.map((tool) => tool.name)),
116-
),
112+
{
113+
...SystemPart.make(
114+
input.agent.system
115+
? input.agent.system
116+
: SessionSystemPrompt.make(input.tools.definitions.map((tool) => tool.name)),
117+
),
118+
// Lets providers with their own base prompt, such as Claude Code, find and replace this one.
119+
metadata: { experimental: { key: "core/prompt" } },
120+
},
117121
{
118122
...SystemPart.make(Instructions.join(input.initial)),
119123
metadata: { experimental: { instructions: input.initial } },

‎packages/core/test/plugin/provider/claude-agent/plugin.test.ts‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -414,7 +414,7 @@ describe("ClaudeAgentPlugin session hooks", () => {
414414
const hooks = yield* PluginHooks.Service
415415
const event = hookEvent((yield* newSession).id)
416416
event.system = [
417-
SystemPart.make("OPENCODE-PROMPT"),
417+
{ ...SystemPart.make("OPENCODE-PROMPT"), metadata: { experimental: { key: "core/prompt" } } },
418418
{
419419
...SystemPart.make("JOINED-INSTRUCTIONS"),
420420
metadata: {
@@ -457,6 +457,32 @@ describe("ClaudeAgentPlugin session hooks", () => {
457457
}),
458458
)
459459

460+
// Claude Code writes its own base prompt and model identity, so OpenCode's tagged parts are left out by tag,
461+
// wherever plugins have moved them, and parts a plugin adds at the front are kept.
462+
it.live("skips the parts Claude Code supplies by tag, not by position", () =>
463+
Effect.gen(function* () {
464+
yield* addPlugin(FakeIO.make({ claude: FakeClaude.make([]) }))
465+
yield* addAgents
466+
const hooks = yield* PluginHooks.Service
467+
yield* hooks.register("session", "context", (event) =>
468+
Effect.sync(() => {
469+
event.system.unshift(SystemPart.make("FIRST-PLUGIN-PART"))
470+
event.system.push(SystemPart.make(""), SystemPart.make("LAST-PLUGIN-PART"))
471+
}),
472+
)
473+
const event = hookEvent((yield* newSession).id)
474+
event.system = [
475+
{ ...SystemPart.make("OPENCODE-PROMPT"), metadata: { experimental: { key: "core/prompt" } } },
476+
{ ...SystemPart.make("IDENTITY"), metadata: { experimental: { key: "core/identity" } } },
477+
SystemPart.make("RULES"),
478+
]
479+
const context = yield* hooks.trigger("session", "context", event)
480+
expect(context.options[ClaudeSystemPrompt.INSTRUCTIONS_OPTION]).toBe(
481+
"FIRST-PLUGIN-PART\n\nRULES\n\nLAST-PLUGIN-PART",
482+
)
483+
}),
484+
)
485+
460486
it.live("lets a later plugin override the instructions outright", () =>
461487
Effect.gen(function* () {
462488
yield* addPlugin(FakeIO.make({ claude: FakeClaude.make([]) }))

0 commit comments

Comments
 (0)