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)) {