diff --git a/src/index.ts b/src/index.ts index d60cfb1..6c56bea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,12 @@ import { isLongContextError, LONG_CONTEXT_BETAS, } from "./betas.ts" -import { transformBody, transformResponseStream } from "./transforms.ts" +import { + looksLikeOpenCodeJoinedBlob, + splitOpenCodeSystemBlob, + transformBody, + transformResponseStream, +} from "./transforms.ts" import { applyOpencodeConfig } from "./plugin-config.ts" import { getCachedCredentials, @@ -35,6 +40,10 @@ export { } from "./betas.ts" export { resetExcludedBetas } from "./betas.ts" export { + isOpenCodeBrandedEntry, + looksLikeOpenCodeJoinedBlob, + normalizeEnvBlock, + splitOpenCodeSystemBlob, stripToolPrefix, transformBody, transformResponseStream, @@ -289,6 +298,39 @@ const plugin: Plugin = async () => { const hasIdentityPrefix = output.system.some((entry) => entry.includes(SYSTEM_IDENTITY_PREFIX), ) + + // OpenCode's session/llm.ts joins provider-prompt + input.system + + // input.user.system into a single string before triggering this hook + // (see anomalyco/opencode `packages/opencode/src/session/llm.ts:88-103`). + // That defeats the per-entry surgical relocation in transformBody: + // the joined blob carries OpenCode fingerprints, so the entire blob + // — AGENTS.md, skills, env, and provider prompt together — gets + // moved out of system[] into the first user message, re-creating + // regression #154. + // + // We split the joined blob back into its constituent pieces here so + // each piece can be evaluated by isOpenCodeBrandedEntry independently. + // Replacing output.system (rather than mutating in place after index 0) + // also bypasses OpenCode's rejoin guard at llm.ts:107-111, which would + // otherwise fold our split entries back into a single string when + // system.length > 2 && system[0] === original header. + const combinedIdx = output.system.findIndex((entry) => + looksLikeOpenCodeJoinedBlob(entry), + ) + if (combinedIdx >= 0) { + const split = splitOpenCodeSystemBlob(output.system[combinedIdx]) + const before = output.system.slice(0, combinedIdx) + const after = output.system.slice(combinedIdx + 1) + output.system.length = 0 + if (!hasIdentityPrefix) output.system.push(SYSTEM_IDENTITY_PREFIX) + output.system.push( + ...before.filter((e) => e !== SYSTEM_IDENTITY_PREFIX), + ...split.filter(Boolean), + ...after, + ) + return + } + if (!hasIdentityPrefix) { output.system.unshift(SYSTEM_IDENTITY_PREFIX) } diff --git a/src/transforms.test.ts b/src/transforms.test.ts index 4172fb1..0fb787e 100644 --- a/src/transforms.test.ts +++ b/src/transforms.test.ts @@ -1,13 +1,362 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" import { + isOpenCodeBrandedEntry, + looksLikeOpenCodeJoinedBlob, + normalizeEnvBlock, repairToolPairs, + splitOpenCodeSystemBlob, stripToolPrefix, transformBody, transformResponseStream, } from "./transforms.ts" +// Fixture: realistic OpenCode joined system blob, mirroring the runtime +// shape produced by anomalyco/opencode `packages/opencode/src/session/llm.ts` +// when the agent prompt + env block + skills + AGENTS.md are joined with +// "\n" before the experimental.chat.system.transform hook fires. +const PROVIDER_PROMPT_HEAD = `You are OpenCode, the best coding agent on the planet. + +You are an interactive CLI tool that helps users with software engineering tasks. +report issues at https://github.com/anomalyco/opencode and see https://opencode.ai/docs` + +const RUNTIME_ENV_BLOCK = `You are powered by the model named claude-opus-4-7. The exact model ID is anthropic/claude-opus-4-7 +Here is some useful information about the environment you are running in: + + Working directory: /Users/gmartin/dev/opencode-claude-auth + Workspace root folder: /Users/gmartin/dev/opencode-claude-auth + Is directory a git repo: yes + Platform: darwin + Today's date: Thu Apr 30 2026 +` + +const SKILLS_BLOCK = `Skills provide specialized instructions and workflows for specific tasks. +Use the skill tool to load a skill when a task matches its description. + + + test-driven-development + Use when implementing features + +` + +const AGENTS_MD_CONTENT = `Instructions from: /Users/gmartin/.claude/CLAUDE.md +## Project Conventions +- Use 2-space indentation +- Run \`pnpm test\` before committing` + +const RUNTIME_JOINED_BLOB = [ + PROVIDER_PROMPT_HEAD, + RUNTIME_ENV_BLOCK, + SKILLS_BLOCK, + AGENTS_MD_CONTENT, +].join("\n") + describe("transforms", () => { + describe("isOpenCodeBrandedEntry", () => { + it("matches 'OpenCode' brand prose (case-sensitive PascalCase)", () => { + assert.equal(isOpenCodeBrandedEntry("You are OpenCode"), true) + assert.equal(isOpenCodeBrandedEntry("the OpenCode CLI assists you"), true) + }) + + it("does NOT match lowercase 'opencode' in paths or URLs alone", () => { + // Common false-positive sources we want to avoid relocating: + // working-directory paths and skill plugin file:// locations. + assert.equal( + isOpenCodeBrandedEntry( + " Working directory: /Users/dev/opencode-claude-auth", + ), + false, + ) + assert.equal( + isOpenCodeBrandedEntry( + "file:///Users/foo/.cache/opencode/skills/x", + ), + false, + ) + }) + + it("matches anomalyco GitHub org", () => { + assert.equal( + isOpenCodeBrandedEntry( + "report at https://github.com/anomalyco/opencode", + ), + true, + ) + assert.equal(isOpenCodeBrandedEntry("see anomalyco for details"), true) + }) + + it("matches opencode.ai docs URL even in lowercase", () => { + assert.equal(isOpenCodeBrandedEntry("see https://opencode.ai/docs"), true) + }) + + it("matches OpenCode-specific env phrase 'Workspace root folder'", () => { + assert.equal( + isOpenCodeBrandedEntry( + "Working directory: /x\nWorkspace root folder: /x", + ), + true, + ) + }) + + it("matches OpenCode-specific env tag", () => { + assert.equal( + isOpenCodeBrandedEntry("\n /x\n"), + true, + ) + }) + + it("does not match plain AGENTS.md project conventions", () => { + const agentsMd = `## Project Conventions +- Use 2-space indentation +- Run \`npm test\` before committing +- Integration tests live in tests/integration/` + assert.equal(isOpenCodeBrandedEntry(agentsMd), false) + }) + + it("does not match Claude Code env format", () => { + const ccEnv = `Here is useful information about the environment you are running in: + +Working directory: /home/user/project +Is directory a git repo: Yes +Platform: linux +Today's date: 2026-04-09 +` + assert.equal(isOpenCodeBrandedEntry(ccEnv), false) + }) + + it("does not match generic skills description block", () => { + const skills = ` + + test-driven-development + Use when implementing features + +` + assert.equal(isOpenCodeBrandedEntry(skills), false) + }) + + it("does not match unrelated words containing 'opencode' as substring", () => { + // Word boundary should prevent false matches on compound words. + assert.equal(isOpenCodeBrandedEntry("the opencoded scheme"), false) + assert.equal(isOpenCodeBrandedEntry("reopencoded"), false) + }) + + it("does not match empty or short strings", () => { + assert.equal(isOpenCodeBrandedEntry(""), false) + assert.equal(isOpenCodeBrandedEntry("hello"), false) + }) + + it("does not match billing header or identity prefix", () => { + assert.equal( + isOpenCodeBrandedEntry( + "x-anthropic-billing-header: cc_version=2.1.90.xxx; cc_entrypoint=cli; cch=abcde;", + ), + false, + ) + assert.equal( + isOpenCodeBrandedEntry( + "You are Claude Code, Anthropic's official CLI for Claude.", + ), + false, + ) + }) + }) + + describe("looksLikeOpenCodeJoinedBlob", () => { + it("identifies the runtime joined blob", () => { + assert.equal(looksLikeOpenCodeJoinedBlob(RUNTIME_JOINED_BLOB), true) + }) + + it("rejects plain AGENTS.md content", () => { + assert.equal(looksLikeOpenCodeJoinedBlob(AGENTS_MD_CONTENT), false) + }) + + it("rejects strings missing the env-block sentinel", () => { + assert.equal( + looksLikeOpenCodeJoinedBlob( + "You are OpenCode\nbut without the env block opener", + ), + false, + ) + }) + + it("rejects strings missing any OpenCode fingerprint", () => { + assert.equal( + looksLikeOpenCodeJoinedBlob( + "You are powered by the model named foo\n\n", + ), + false, + ) + }) + }) + + describe("normalizeEnvBlock", () => { + it("splits OpenCode env into safe + branded", () => { + const { safe, branded } = normalizeEnvBlock(RUNTIME_ENV_BLOCK) + assert.ok(safe, "safe env should be produced") + assert.ok(branded, "branded extras should be produced") + // Safe env keeps Claude-Code-shaped lines only. + assert.ok(safe!.includes("Working directory:")) + assert.ok(safe!.includes("Is directory a git repo:")) + assert.ok(safe!.includes("Platform:")) + assert.ok(safe!.includes("Today's date:")) + assert.ok(safe!.startsWith("Here is useful information")) + // Safe env strips OpenCode-only lines. + assert.ok(!safe!.includes("Workspace root folder")) + assert.ok(!safe!.includes("You are powered by")) + // Branded captures the OpenCode-only opener and Workspace root line. + assert.ok(branded!.includes("You are powered by the model named")) + assert.ok(branded!.includes("Workspace root folder:")) + }) + + it("returns input unchanged when env body has no OpenCode-only lines", () => { + const ccShaped = + "Here is useful information about the environment you are running in:\n" + + "\n Working directory: /x\n Today's date: 2026\n" + const { safe, branded } = normalizeEnvBlock(ccShaped) + assert.ok(safe) + assert.equal(branded, null) + }) + + it("falls back gracefully when input has no container", () => { + const garbage = "not really an env block" + const { safe, branded } = normalizeEnvBlock(garbage) + assert.equal(safe, null) + assert.equal(branded, garbage) + }) + }) + + describe("splitOpenCodeSystemBlob", () => { + it("returns input unchanged for non-combined shapes", () => { + assert.deepEqual(splitOpenCodeSystemBlob("hello"), ["hello"]) + assert.deepEqual(splitOpenCodeSystemBlob(AGENTS_MD_CONTENT), [ + AGENTS_MD_CONTENT, + ]) + }) + + it("splits the runtime joined blob into ordered segments", () => { + const parts = splitOpenCodeSystemBlob(RUNTIME_JOINED_BLOB) + // Expect: [head, branded-env-extras, safe-env, skills, AGENTS] + assert.equal(parts.length, 5) + assert.equal(parts[0], PROVIDER_PROMPT_HEAD) + assert.ok(parts[1].includes("You are powered by the model named")) + assert.ok(parts[1].includes("Workspace root folder:")) + assert.ok(parts[2].includes("")) + assert.ok(!parts[2].includes("Workspace root folder")) + assert.ok(parts[2].includes("Working directory:")) + assert.equal(parts[3], SKILLS_BLOCK) + assert.equal(parts[4], AGENTS_MD_CONTENT) + }) + + it("handles a blob without a skills block", () => { + const blob = [ + PROVIDER_PROMPT_HEAD, + RUNTIME_ENV_BLOCK, + AGENTS_MD_CONTENT, + ].join("\n") + const parts = splitOpenCodeSystemBlob(blob) + assert.equal(parts.length, 4) + assert.equal(parts[0], PROVIDER_PROMPT_HEAD) + assert.equal(parts[3], AGENTS_MD_CONTENT) + }) + + it("handles a blob without any AGENTS instructions", () => { + const blob = [PROVIDER_PROMPT_HEAD, RUNTIME_ENV_BLOCK, SKILLS_BLOCK].join( + "\n", + ) + const parts = splitOpenCodeSystemBlob(blob) + // [head, branded-env-extras, safe-env, skills] + assert.equal(parts.length, 4) + assert.equal(parts[3], SKILLS_BLOCK) + }) + + it("preserves multiple AGENTS/CLAUDE.md instruction blocks", () => { + const second = `Instructions from: /Users/gmartin/.opencode/AGENTS.md\n## Global rules` + const blob = [ + PROVIDER_PROMPT_HEAD, + RUNTIME_ENV_BLOCK, + AGENTS_MD_CONTENT, + second, + ].join("\n") + const parts = splitOpenCodeSystemBlob(blob) + assert.equal(parts[parts.length - 2], AGENTS_MD_CONTENT) + assert.equal(parts[parts.length - 1], second) + }) + + it("falls back to a single-entry array when env block is malformed", () => { + const blob = `${PROVIDER_PROMPT_HEAD}\nYou are powered by the model named foo\n\nno closing tag` + const parts = splitOpenCodeSystemBlob(blob) + // No -> ENV_BLOCK_RE doesn't match -> pass through. + assert.deepEqual(parts, [blob]) + }) + }) + + it("runtime pipeline: joined blob -> hook split -> transformBody keeps AGENTS and safe env in system[]", () => { + // Emulate what experimental.chat.system.transform does: split the + // OpenCode joined blob, prepend the Claude Code identity, then have + // OpenCode wrap each entry as a separate system message which the + // Anthropic provider serializes into request body system: [...]. + const SYSTEM_IDENTITY = + "You are Claude Code, Anthropic's official CLI for Claude." + const split = splitOpenCodeSystemBlob(RUNTIME_JOINED_BLOB) + const systemEntriesAfterHook = [SYSTEM_IDENTITY, ...split] + + const input = JSON.stringify({ + system: systemEntriesAfterHook.map((text) => ({ type: "text", text })), + messages: [{ role: "user", content: "hello" }], + }) + + const output = transformBody(input) + const parsed = JSON.parse(output as string) as { + system: Array<{ text: string }> + messages: Array<{ content: string }> + } + + const sysTexts = parsed.system.map((e) => e.text) + + // Billing header is always system[0]. + assert.ok(sysTexts[0].startsWith("x-anthropic-billing-header:")) + // Identity must remain in system[]. + assert.ok(sysTexts.some((t) => t === SYSTEM_IDENTITY)) + // AGENTS.md content survives in system[] (the regression #154 was about). + assert.ok( + sysTexts.some((t) => t.includes("Project Conventions")), + "AGENTS.md must remain in system[] after the hook+transformBody pipeline", + ) + // Safe env block survives in system[]. + assert.ok( + sysTexts.some((t) => t.includes("") && t.includes("Today's date:")), + "Safe env should remain in system[]", + ) + // Skills block: under the current OPENCODE_FEATURE_PATTERNS, skills + // entries that do not themselves carry OpenCode brand text remain in + // system[]. Our fixture skills block is generic. + assert.ok( + sysTexts.some((t) => t.includes("")), + "Generic skills block should remain in system[]", + ) + + // Branded items (provider prompt, env opener + Workspace root line) + // must be relocated to the first user message. + const userContent = parsed.messages[0].content + assert.ok( + userContent.includes("You are OpenCode, the best coding agent"), + "Provider prompt should relocate to the user message", + ) + assert.ok( + userContent.includes("Workspace root folder:"), + "OpenCode-only Workspace root line should relocate to the user message", + ) + // And those branded items should not also be in system[]. + assert.ok( + !sysTexts.some((t) => t.includes("You are OpenCode")), + "Provider prompt must not remain in system[]", + ) + assert.ok( + !sysTexts.some((t) => t.includes("Workspace root folder:")), + "Branded env line must not remain in system[]", + ) + }) + it("transformBody moves non-core system text to user message and PascalCase-prefixes tool names", () => { const input = JSON.stringify({ system: [{ type: "text", text: "OpenCode and opencode" }], @@ -41,13 +390,9 @@ describe("transforms", () => { }) it("transformBody relocates non-core system text to user message", () => { + const branded = "Use the OpenCode plugin instructions as-is." const input = JSON.stringify({ - system: [ - { - type: "text", - text: "Use opencode-claude-auth plugin instructions as-is.", - }, - ], + system: [{ type: "text", text: branded }], messages: [{ role: "user", content: "hello" }], }) @@ -60,11 +405,7 @@ describe("transforms", () => { // Non-core system text should be moved to user message assert.equal(parsed.system.length, 1) // only billing header - assert.ok( - parsed.messages[0].content.includes( - "Use opencode-claude-auth plugin instructions as-is.", - ), - ) + assert.ok(parsed.messages[0].content.includes(branded)) }) it("transformBody relocates URL/path system text to user message", () => { @@ -133,7 +474,7 @@ describe("transforms", () => { ) }) - it("transformBody splits concatenated identity prefix and relocates remainder to user message", () => { + it("transformBody splits concatenated identity prefix and keeps non-branded remainder in system", () => { const identity = "You are Claude Code, Anthropic's official CLI for Claude." const input = JSON.stringify({ system: [ @@ -151,17 +492,17 @@ describe("transforms", () => { messages: Array<{ content: string }> } - // system[0] = billing header, system[1] = identity prefix + // system[0] = billing header, system[1] = identity prefix, + // system[2] = non-branded remainder (stays in system[]) + assert.equal(parsed.system.length, 3) assert.ok(parsed.system[0].text.startsWith("x-anthropic-billing-header:")) assert.equal(parsed.system[1].text, identity) - // remainder is relocated to user message - assert.equal(parsed.system.length, 2) - assert.ok( - parsed.messages[0].content.includes("Working directory: /home/test"), - ) + assert.equal(parsed.system[2].text, "Working directory: /home/test") + // User message is unchanged (no injection) + assert.equal(parsed.messages[0].content, "test") }) - it("transformBody preserves identity without cache_control and relocates remainder", () => { + it("transformBody preserves identity without cache_control and keeps non-branded remainder in system", () => { const identity = "You are Claude Code, Anthropic's official CLI for Claude." const input = JSON.stringify({ system: [ @@ -186,9 +527,15 @@ describe("transforms", () => { undefined, "Identity block must not have cache_control", ) - // Remainder is relocated to user message, not kept in system - assert.equal(parsed.system.length, 2) - assert.ok(parsed.messages[0].content.includes("More content here")) + // Remainder is non-branded, so it stays in system[] with its cache_control + assert.equal(parsed.system.length, 3) + assert.equal(parsed.system[2].text, "More content here") + assert.deepEqual(parsed.system[2].cache_control, { + type: "ephemeral", + ttl: "1h", + }) + // User message is unchanged + assert.equal(parsed.messages[0].content, "test") }) it("transformBody does not split identity-only system entry", () => { @@ -208,7 +555,7 @@ describe("transforms", () => { assert.equal(parsed.system[1].text, identity) }) - it("transformBody removes duplicate billing headers and relocates non-core text", () => { + it("transformBody removes duplicate billing headers and keeps non-branded text in system", () => { const input = JSON.stringify({ system: [ { @@ -238,11 +585,14 @@ describe("transforms", () => { billingEntries[0].text.includes("cch=fa690"), `Expected computed cch, got: ${billingEntries[0].text}`, ) - // "prompt" should be relocated to user message - assert.ok(parsed.messages[0].content.includes("prompt")) + // "prompt" is non-branded, so it stays in system[] + const promptEntry = parsed.system.find((e) => e.text === "prompt") + assert.ok(promptEntry, "'prompt' should remain in system array") + // User message is unchanged (no prefix injection) + assert.equal(parsed.messages[0].content, "hey") }) - it("transformBody relocates multiple non-core system entries to user message as content blocks", () => { + it("transformBody keeps multiple non-branded system entries in the system array", () => { const identity = "You are Claude Code, Anthropic's official CLI for Claude." const input = JSON.stringify({ system: [ @@ -266,24 +616,169 @@ describe("transforms", () => { }> } - // system should only have billing header + identity + // system should have billing + identity + both custom blocks (all stay) + assert.equal(parsed.system.length, 4) + assert.ok(parsed.system[0].text.startsWith("x-anthropic-billing-header:")) + assert.equal(parsed.system[1].text, identity) + assert.equal(parsed.system[2].text, "Custom instructions block A") + assert.equal(parsed.system[3].text, "Custom instructions block B") + // User message is unchanged (no prefix injection) + assert.equal(parsed.messages[0].content.length, 1) + assert.equal(parsed.messages[0].content[0].text, "hello") + }) + + it("transformBody keeps plain AGENTS.md content in system array", () => { + const agentsMd = `## Project Conventions +- Use 2-space indentation +- Run \`pnpm test\` before committing` + const input = JSON.stringify({ + system: [{ type: "text", text: agentsMd }], + messages: [{ role: "user", content: "hello" }], + }) + + const output = transformBody(input) + const parsed = JSON.parse(output as string) as { + system: Array<{ text: string }> + messages: Array<{ content: string }> + } + + // AGENTS.md content has no OpenCode markers — stays in system[] assert.equal(parsed.system.length, 2) assert.ok(parsed.system[0].text.startsWith("x-anthropic-billing-header:")) + assert.equal(parsed.system[1].text, agentsMd) + // User message unchanged + assert.equal(parsed.messages[0].content, "hello") + }) + + it("transformBody keeps Claude Code-format env block in system array", () => { + const ccEnvBlock = `Here is useful information about the environment you are running in: + +Working directory: /home/user/project +Is directory a git repo: Yes +Platform: linux +Today's date: 2026-04-09 +` + const input = JSON.stringify({ + system: [{ type: "text", text: ccEnvBlock }], + messages: [{ role: "user", content: "hello" }], + }) + + const output = transformBody(input) + const parsed = JSON.parse(output as string) as { + system: Array<{ text: string }> + messages: Array<{ content: string }> + } + + // CC-format env has no OpenCode-specific features — stays in system[] + assert.equal(parsed.system.length, 2) + assert.equal(parsed.system[1].text, ccEnvBlock) + assert.equal(parsed.messages[0].content, "hello") + }) + + it("transformBody keeps generic skills block in system array", () => { + const skillsBlock = ` + + test-driven-development + Use when implementing features + +` + const input = JSON.stringify({ + system: [{ type: "text", text: skillsBlock }], + messages: [{ role: "user", content: "hello" }], + }) + + const output = transformBody(input) + const parsed = JSON.parse(output as string) as { + system: Array<{ text: string }> + messages: Array<{ content: string }> + } + + // Skills block has no OpenCode markers — stays in system[] + assert.equal(parsed.system.length, 2) + assert.equal(parsed.system[1].text, skillsBlock) + }) + + it("transformBody relocates entry containing anomalyco URL", () => { + const brandedEntry = + "Report feedback at https://github.com/anomalyco/opencode/issues" + const input = JSON.stringify({ + system: [{ type: "text", text: brandedEntry }], + messages: [{ role: "user", content: "hello" }], + }) + + const output = transformBody(input) + const parsed = JSON.parse(output as string) as { + system: Array<{ text: string }> + messages: Array<{ content: string }> + } + + // Branded entry is relocated; system has only billing header + assert.equal(parsed.system.length, 1) + assert.ok(parsed.system[0].text.startsWith("x-anthropic-billing-header:")) + assert.ok(parsed.messages[0].content.includes(brandedEntry)) + }) + + it("transformBody relocates OpenCode env block with tag", () => { + const opencodeEnv = `Here is some useful information about the environment you are running in: + + Working directory: /home/user/project + Workspace root folder: /home/user/project + + + /home/user/project +` + const input = JSON.stringify({ + system: [{ type: "text", text: opencodeEnv }], + messages: [{ role: "user", content: "hello" }], + }) + + const output = transformBody(input) + const parsed = JSON.parse(output as string) as { + system: Array<{ text: string }> + messages: Array<{ content: string }> + } + + // Contains BOTH 'Workspace root folder' and '' — relocated + assert.equal(parsed.system.length, 1) + assert.ok(parsed.messages[0].content.includes("")) + assert.ok(parsed.messages[0].content.includes("Workspace root folder")) + }) + + it("transformBody surgically relocates only branded entries in mixed system input", () => { + const identity = "You are Claude Code, Anthropic's official CLI for Claude." + const agentsMd = "## Conventions\n- Use 2-space indentation" + const ccEnvBlock = `\nWorking directory: /x\nIs directory a git repo: Yes\n` + const opencodeCore = + "You are OpenCode, the best coding agent on the planet." + const skillsBlock = `\n \n tdd\n \n` + + const input = JSON.stringify({ + system: [ + { type: "text", text: identity }, + { type: "text", text: agentsMd }, + { type: "text", text: ccEnvBlock }, + { type: "text", text: opencodeCore }, + { type: "text", text: skillsBlock }, + ], + messages: [{ role: "user", content: "hello" }], + }) + + const output = transformBody(input) + const parsed = JSON.parse(output as string) as { + system: Array<{ text: string }> + messages: Array<{ content: string }> + } + + // Kept in system: billing + identity + agentsMd + ccEnvBlock + skillsBlock + assert.equal(parsed.system.length, 5) + assert.ok(parsed.system[0].text.startsWith("x-anthropic-billing-header:")) assert.equal(parsed.system[1].text, identity) - // Both custom blocks should be prepended to user message content - assert.equal(parsed.messages[0].content[0].type, "text") - assert.ok( - parsed.messages[0].content[0].text.includes( - "Custom instructions block A", - ), - ) - assert.ok( - parsed.messages[0].content[0].text.includes( - "Custom instructions block B", - ), - ) - // Original user content preserved - assert.equal(parsed.messages[0].content[1].text, "hello") + assert.equal(parsed.system[2].text, agentsMd) + assert.equal(parsed.system[3].text, ccEnvBlock) + assert.equal(parsed.system[4].text, skillsBlock) + // Only opencodeCore is relocated to user message + assert.ok(parsed.messages[0].content.includes(opencodeCore)) + assert.ok(!parsed.messages[0].content.includes(agentsMd)) }) it("transformBody keeps system intact when no messages exist", () => { diff --git a/src/transforms.ts b/src/transforms.ts index 713cc4d..0c83479 100644 --- a/src/transforms.ts +++ b/src/transforms.ts @@ -22,6 +22,206 @@ function unprefixName(name: string): string { const SYSTEM_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude." +/** + * Patterns that identify OpenCode-fingerprinted content in system entries. + * + * Anthropic's OAuth API runs a content classifier on the system[] array that + * rejects requests containing OpenCode fingerprints with a misleading 400 + * "out of extra usage" error (see issues #147 and #154). Empirical probing + * (April 2026) showed the classifier is multi-feature rather than simple + * substring matching — no single feature triggers the check in isolation, + * but combinations do. Rather than try to track the exact classifier + * threshold, we relocate any entry containing a known OpenCode feature. + * + * Entries matching any of these patterns are moved to the first user + * message; entries that don't match stay in system[] where they retain + * attention priority and prompt-cache efficiency. + */ +const OPENCODE_FEATURE_PATTERNS: RegExp[] = [ + // Brand prose. Case-SENSITIVE PascalCase: anthropic.txt and other + // OpenCode prompts use "OpenCode" consistently. Lowercase "opencode" + // appears almost exclusively in directory/path components like + // /Users/foo/dev/opencode-claude-auth/... or + // /Users/foo/.cache/opencode/... and matching those produces false + // positives that relocate non-branded content (e.g. user env blocks, + // skills entries) needlessly. + /\bOpenCode\b/, + // GitHub org used by OpenCode repositories + // (e.g. https://github.com/anomalyco/opencode). + /\banomalyco\b/i, + // OpenCode docs/site URL — strong contextual brand signal even in + // lowercase form. + /\bopencode\.ai\b/i, + // OpenCode-specific env metadata phrase. Claude Code's env block only + // uses "Working directory"; "Workspace root folder" is OpenCode-only. + /Workspace root folder/, + // OpenCode-specific env tag. Claude Code's env block has no + // section. + //, +] + +/** + * Returns true if the given system entry text contains any OpenCode + * fingerprint feature that would trigger Anthropic's OAuth content + * classifier. Such entries must be relocated out of system[] before + * sending the request. + * + * Non-matching entries (plain AGENTS.md, generic env blocks, skill lists, + * etc.) are safe to keep in system[]. + */ +export function isOpenCodeBrandedEntry(text: string): boolean { + return OPENCODE_FEATURE_PATTERNS.some((pattern) => pattern.test(text)) +} + +/** + * Anchors used by splitOpenCodeSystemBlob to recognize OpenCode's joined + * system blob. These mirror upstream OpenCode source as of dev branch: + * + * - ENV_BLOCK_RE: `packages/opencode/src/session/system.ts` `environment()` + * produces "You are powered by the model named ...\n...\n..." + * - SKILLS_BLOCK_RE: superpowers (and similar plugins) emit + * "Skills provide specialized instructions...\n..." + * - INSTRUCTIONS_HEADER_RE: `packages/opencode/src/session/instruction.ts` + * emits each AGENTS.md/CLAUDE.md as "Instructions from: \n" + * + * Splitting is best-effort and gracefully degrades to pass-through for + * unfamiliar shapes, so behavior is never worse than v1.4.8 bulk relocation. + */ +const ENV_BLOCK_RE = + /^You are powered by the model named [^\n]+\n[\s\S]*?<\/env>/m +const SKILLS_BLOCK_RE = + /^Skills provide specialized instructions and workflows for specific tasks\.\n[\s\S]*?<\/available_skills>/m +const INSTRUCTIONS_HEADER_RE = /\n(?=Instructions from: [/~])/ +const ENV_OPENER_RE = + /^You are powered by the model named [^\n]+\nHere is some useful information about the environment you are running in:\n/ +const ENV_WORKSPACE_ROOT_RE = /^[ \t]*Workspace root folder:[^\n]*\n?/m +const ENV_CONTAINER_RE = /([\s\S]*?)<\/env>/ + +/** + * Normalize an OpenCode env block into two pieces: a Claude-Code-shaped + * "safe" env that can stay in system[], and a "branded" extras string + * containing the OpenCode-only opener and `Workspace root folder` line that + * must relocate. + * + * Returns `{ safe: null, branded: input }` when the env block doesn't match + * the expected shape — graceful fallback that preserves current behavior + * (relocate the whole thing). + */ +export function normalizeEnvBlock(envBlock: string): { + safe: string | null + branded: string | null +} { + const containerMatch = envBlock.match(ENV_CONTAINER_RE) + if (!containerMatch) { + return { safe: null, branded: envBlock } + } + + // Pull off the OpenCode-only opener (two lines) before "". + const openerMatch = envBlock.match(ENV_OPENER_RE) + const opener = openerMatch ? openerMatch[0] : "" + + // Strip the OpenCode-only "Workspace root folder: ..." line from the + // body, leaving only Claude-Code-format lines. + const envBody = containerMatch[1] + const workspaceLineMatch = envBody.match(ENV_WORKSPACE_ROOT_RE) + const workspaceLine = workspaceLineMatch ? workspaceLineMatch[0] : "" + const safeBody = envBody.replace(ENV_WORKSPACE_ROOT_RE, "") + + // Compose the safe env (Claude-Code-shaped) and branded extras. + const safe = + `Here is useful information about the environment you are running in:\n` + + `${safeBody}` + + const brandedParts: string[] = [] + if (opener) brandedParts.push(opener.trimEnd()) + if (workspaceLine) brandedParts.push(workspaceLine.trim()) + const branded = brandedParts.length > 0 ? brandedParts.join("\n") : null + + return { safe, branded } +} + +/** + * Split OpenCode's joined system blob (produced by + * `packages/opencode/src/session/llm.ts:88-103`) back into its constituent + * entries so that downstream surgical relocation in `transformBody` can + * keep non-branded pieces (AGENTS.md, skills, safe env) in `system[]`. + * + * For unfamiliar shapes the input is returned unchanged as a single-entry + * array, so callers can pass any string through without a behavioral + * regression. + * + * The returned ordering preserves OpenCode's original assembly order: + * [providerPromptHead?, brandedEnvExtras?, safeEnv?, skillsBlock?, + * ...instructionBlocks, userSystemTail?] + */ +export function splitOpenCodeSystemBlob(text: string): string[] { + const envMatch = text.match(ENV_BLOCK_RE) + if (!envMatch || envMatch.index === undefined) { + return [text] + } + + // Slice the joined string at the env-block boundary. + const head = text.slice(0, envMatch.index).replace(/\n+$/, "") + const envBlock = envMatch[0] + let tail = text.slice(envMatch.index + envBlock.length).replace(/^\n+/, "") + + // Normalize the env block. + const { safe, branded } = normalizeEnvBlock(envBlock) + + // Optionally peel off a skills block from the tail. + let skillsBlock: string | null = null + const skillsMatch = tail.match(SKILLS_BLOCK_RE) + if (skillsMatch && skillsMatch.index !== undefined) { + skillsBlock = skillsMatch[0] + const before = tail.slice(0, skillsMatch.index).replace(/\n+$/, "") + const after = tail + .slice(skillsMatch.index + skillsBlock.length) + .replace(/^\n+/, "") + tail = [before, after].filter(Boolean).join("\n") + } + + // Split remaining tail on "Instructions from: ..." headers — these are + // AGENTS.md / CLAUDE.md blocks emitted one per file by upstream + // `Instruction.system()`. + const instructionPieces: string[] = [] + let userSystemTail: string | null = null + if (tail) { + const parts = tail.split(INSTRUCTIONS_HEADER_RE) + // Anything before the first "Instructions from:" header is non-AGENTS + // tail (typically input.user.system or a structured-output prompt). + if (parts.length > 0 && !parts[0].startsWith("Instructions from:")) { + const firstTail = parts.shift()! + if (firstTail.trim()) userSystemTail = firstTail + } + for (const part of parts) { + if (part.trim()) instructionPieces.push(part) + } + } + + const out: string[] = [] + if (head) out.push(head) + if (branded) out.push(branded) + if (safe) out.push(safe) + if (skillsBlock) out.push(skillsBlock) + out.push(...instructionPieces) + if (userSystemTail) out.push(userSystemTail) + return out +} + +/** + * Heuristic: does this string look like OpenCode's joined system blob? + * Used by callers (the system.transform hook) to decide whether to invoke + * splitOpenCodeSystemBlob. We require both the env-block opener sentinel + * and at least one OpenCode brand fingerprint, so unrelated multi-section + * prompts pass through unchanged. + */ +export function looksLikeOpenCodeJoinedBlob(text: string): boolean { + return ( + /^You are powered by the model named/m.test(text) && + /\bopencode\b/i.test(text) + ) +} + type SystemEntry = { type?: string; text?: string } & Record type ContentBlock = { type?: string; text?: string } & Record type Message = { @@ -169,15 +369,21 @@ export function transformBody( } parsed.system = splitSystem - // --- Relocate non-core system entries to user messages --- - // Anthropic's API now validates the system prompt for OAuth-authenticated - // requests that use Claude Code billing. Third-party system prompts - // (like OpenCode's) trigger a 400 "out of extra usage" rejection when - // they appear inside the system[] array alongside the identity prefix. + // --- Surgically relocate OpenCode-fingerprinted system entries --- + // Anthropic's OAuth API runs a content classifier on the system[] array + // that rejects requests containing OpenCode fingerprints (see #147). + // The v1.4.8 fix (#148) worked around this by bulk-relocating ALL + // non-core system entries to the first user message, but this caused + // a regression in instruction-following for long conversations (#154) + // because system-level priority and prompt-cache efficiency were lost. // - // Work-around: keep only the billing header and identity prefix in - // system[], and prepend all other system content to the first user - // message where it is functionally equivalent but avoids the check. + // Empirical probing showed the classifier is feature-based: specific + // OpenCode markers (brand strings, anomalyco URLs, "Workspace root + // folder", ) trigger the check, while AGENTS.md, skills + // blocks, Claude Code-format env blocks, and other non-branded content + // do not. We now relocate only entries matching an OpenCode feature + // pattern (see isOpenCodeBrandedEntry), keeping everything else in + // system[] where it retains full attention priority and caches well. const BILLING_PREFIX = "x-anthropic-billing-header" const keptSystem: SystemEntry[] = [] const movedTexts: string[] = [] @@ -185,8 +391,10 @@ export function transformBody( const txt = typeof entry === "string" ? entry : (entry.text ?? "") if (txt.startsWith(BILLING_PREFIX) || txt.startsWith(SYSTEM_IDENTITY)) { keptSystem.push(entry) - } else if (txt.length > 0) { + } else if (txt.length > 0 && isOpenCodeBrandedEntry(txt)) { movedTexts.push(txt) + } else { + keptSystem.push(entry) } } if (movedTexts.length > 0 && Array.isArray(parsed.messages)) {