Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 56 additions & 56 deletions src/transforms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
} from "./transforms.ts"

describe("transforms", () => {
it("transformBody moves non-core system text to user message and PascalCase-prefixes tool names", () => {
it("transformBody keeps safe system text in system[] and PascalCase-prefixes tool names", () => {
const input = JSON.stringify({
system: [{ type: "text", text: "OpenCode and opencode" }],
tools: [{ name: "search" }],
Expand All @@ -27,20 +27,42 @@ describe("transforms", () => {
}>
}

// system should only contain the billing header (non-core text relocated)
assert.equal(parsed.system.length, 1)
assert.ok(
parsed.system[0].text.startsWith("x-anthropic-billing-header:"),
"system[0] should be the billing header",
)
// The original system text should now be prepended to the first user message
assert.equal(parsed.messages[0].content[0].type, "text")
assert.equal(parsed.messages[0].content[0].text, "OpenCode and opencode")
// safe text stays in system[] (billing + original)
assert.equal(parsed.system.length, 2)
assert.ok(parsed.system[0].text.startsWith("x-anthropic-billing-header:"))
assert.equal(parsed.system[1].text, "OpenCode and opencode")
assert.equal(parsed.tools[0].name, "mcp_Search")
assert.equal(parsed.messages[0].content[0].name, "mcp_Lookup")
})

it("transformBody scrubs blocked URL from system text in-place", () => {
const input = JSON.stringify({
system: [{ type: "text", text: "Report at https://github.com/anomalyco/opencode for bugs" }],
tools: [{ name: "search" }],
messages: [
{ role: "user", content: [{ type: "tool_use", name: "lookup" }] },
],
})

const output = transformBody(input)
assert.equal(typeof output, "string")
const parsed = JSON.parse(output as string) as {
system: Array<{ text: string }>
tools: Array<{ name: string }>
messages: Array<{
content: Array<{ type?: string; text?: string; name?: string }>
}>
}

// blocked URL scrubbed, entry stays in system[]
assert.equal(parsed.system.length, 2) // billing + scrubbed entry
assert.ok(!parsed.system[1].text.includes("anomalyco"))
assert.ok(parsed.system[1].text.includes("Report at"))
assert.equal(parsed.tools[0].name, "mcp_Search")
assert.equal(parsed.messages[0].content[1].name, "mcp_Lookup")
assert.equal(parsed.messages[0].content[0].name, "mcp_Lookup")
})

it("transformBody relocates non-core system text to user message", () => {
it("transformBody keeps safe non-core system text in system[]", () => {
const input = JSON.stringify({
system: [
{
Expand All @@ -58,16 +80,12 @@ describe("transforms", () => {
messages: Array<{ content: string }>
}

// 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.",
),
)
// Safe text stays in system[]
assert.equal(parsed.system.length, 2) // billing + safe text
assert.equal(parsed.system[1].text, "Use opencode-claude-auth plugin instructions as-is.")
})

it("transformBody relocates URL/path system text to user message", () => {
it("transformBody keeps safe URL/path system text in system[]", () => {
const input = JSON.stringify({
system: [
{
Expand All @@ -85,13 +103,9 @@ describe("transforms", () => {
messages: Array<{ content: string }>
}

// Non-core system text should be relocated
assert.equal(parsed.system.length, 1) // only billing header
assert.ok(
parsed.messages[0].content.includes(
"OpenCode docs: https://example.com/opencode/docs and path /var/opencode/bin",
),
)
// Safe URLs stay in system[]
assert.equal(parsed.system.length, 2)
assert.equal(parsed.system[1].text, "OpenCode docs: https://example.com/opencode/docs and path /var/opencode/bin")
})

it("transformBody injects billing header as system[0] with computed cch", () => {
Expand Down Expand Up @@ -151,14 +165,11 @@ describe("transforms", () => {
messages: Array<{ content: string }>
}

// system[0] = billing header, system[1] = identity prefix
// system[0] = billing header, system[1] = identity prefix, system[2] = remainder (safe)
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.length, 3)
assert.equal(parsed.system[2].text, "Working directory: /home/test")
})

it("transformBody preserves identity without cache_control and relocates remainder", () => {
Expand Down Expand Up @@ -186,9 +197,9 @@ 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 stays in system[] (safe content)
assert.equal(parsed.system.length, 3)
assert.equal(parsed.system[2].text, "More content here")
})

it("transformBody does not split identity-only system entry", () => {
Expand Down Expand Up @@ -238,8 +249,8 @@ 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 safe, stays in system[]
assert.ok(parsed.system.some((e) => e.text === "prompt"))
})

it("transformBody relocates multiple non-core system entries to user message as content blocks", () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Stale test description contradicts new behavior

The test is named "transformBody relocates multiple non-core system entries to user message as content blocks", but the body now asserts the exact opposite: that all entries stay in system[] (parsed.system.length === 4). This will confuse anyone reading the test output or running it in watch mode — a failing test's name would describe behavior that no longer applies.

Suggested change
it("transformBody relocates multiple non-core system entries to user message as content blocks", () => {
it("transformBody keeps multiple safe non-core system entries in system[]", () => {
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/transforms.test.ts
Line: 256

Comment:
**Stale test description contradicts new behavior**

The test is named `"transformBody relocates multiple non-core system entries to user message as content blocks"`, but the body now asserts the exact opposite: that all entries stay in `system[]` (`parsed.system.length === 4`). This will confuse anyone reading the test output or running it in watch mode — a failing test's name would describe behavior that no longer applies.

```suggestion
  it("transformBody keeps multiple safe non-core system entries in system[]", () => {
```

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Cursor Fix in Claude Code Fix in Codex

Expand All @@ -266,24 +277,14 @@ describe("transforms", () => {
}>
}

// system should only have billing header + identity
assert.equal(parsed.system.length, 2)
// Safe custom blocks stay in system[]
assert.equal(parsed.system.length, 4)
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, "Custom instructions block A")
assert.equal(parsed.system[3].text, "Custom instructions block B")
// Original user content unchanged
assert.equal(parsed.messages[0].content[0].text, "hello")
})

it("transformBody keeps system intact when no messages exist", () => {
Expand Down Expand Up @@ -811,8 +812,7 @@ describe("transforms", () => {
messages: Array<{ role: string; content: unknown }>
}

// Orphaned tool_use message should be removed.
// The user message remains, with the relocated system "prompt" prepended.
// Orphaned tool_use message removed. User message stays.
assert.equal(parsed.messages.length, 1)
assert.equal(parsed.messages[0].role, "user")
assert.ok(
Expand Down
54 changes: 26 additions & 28 deletions src/transforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,35 +169,33 @@ 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.
//
// 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.
const BILLING_PREFIX = "x-anthropic-billing-header"
const keptSystem: SystemEntry[] = []
const movedTexts: string[] = []
for (const entry of parsed.system) {
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) {
movedTexts.push(txt)
}
// --- Relocate blocked system entries to user messages ---
// Anthropic's billing validator rejects specific URLs in system[]
// (e.g. the OpenCode GitHub repo URL). Instead of moving ALL
// non-core system content (which regresses instruction priority
// and prompt-cache efficiency), only relocate entries that contain
// a blocked string. Everything else stays in system[].
const BLOCKED_SYSTEM_STRINGS = [
"github.com/anomalyco/opencode",
]

function isBlocked(text: string): boolean {
return BLOCKED_SYSTEM_STRINGS.some((s) => text.includes(s))
}
if (movedTexts.length > 0 && Array.isArray(parsed.messages)) {
const firstUser = parsed.messages.find((m) => m.role === "user")
if (firstUser) {
parsed.system = keptSystem
const prefix = movedTexts.join("\n\n")
if (typeof firstUser.content === "string") {
firstUser.content = prefix + "\n\n" + firstUser.content
} else if (Array.isArray(firstUser.content)) {
firstUser.content.unshift({ type: "text", text: prefix })

// Scrub blocked strings from system entries in-place rather than
// relocating entire entries. OpenCode concatenates the full system
// prompt (identity + agent prompt + env + AGENTS + skills) into a
// single text block, so moving the whole entry on a substring match
// would frontload the entire prompt into the user message.
for (const entry of parsed.system) {
if (
entry.type === "text" &&
typeof entry.text === "string" &&
isBlocked(entry.text)
) {
for (const blocked of BLOCKED_SYSTEM_STRINGS) {
entry.text = entry.text.split(blocked).join("")
}
}
Comment on lines +191 to 200

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Empty system entry left after full-URL scrub

If a system entry contains only the blocked URL (e.g. { type: "text", text: "github.com/anomalyco/opencode" }), scrubbing reduces entry.text to "". That empty-text entry is then forwarded to Anthropic's API, which may reject it or silently alter billing/validation logic. The previous implementation explicitly checked txt.length > 0 before including text in movedTexts, preventing this. The new code has no equivalent guard — after the mutation loop, entries with entry.text === "" remain in parsed.system unchanged.

Suggested change
for (const entry of parsed.system) {
if (
entry.type === "text" &&
typeof entry.text === "string" &&
isBlocked(entry.text)
) {
for (const blocked of BLOCKED_SYSTEM_STRINGS) {
entry.text = entry.text.split(blocked).join("")
}
}
for (const entry of parsed.system) {
if (
entry.type === "text" &&
typeof entry.text === "string" &&
isBlocked(entry.text)
) {
for (const blocked of BLOCKED_SYSTEM_STRINGS) {
entry.text = entry.text.split(blocked).join("")
}
}
}
// Remove any entries that were reduced to empty strings by scrubbing.
parsed.system = parsed.system.filter(
(e) => !(e.type === "text" && e.text === ""),
)
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/transforms.ts
Line: 191-200

Comment:
**Empty system entry left after full-URL scrub**

If a system entry contains *only* the blocked URL (e.g. `{ type: "text", text: "github.com/anomalyco/opencode" }`), scrubbing reduces `entry.text` to `""`. That empty-text entry is then forwarded to Anthropic's API, which may reject it or silently alter billing/validation logic. The previous implementation explicitly checked `txt.length > 0` before including text in `movedTexts`, preventing this. The new code has no equivalent guard — after the mutation loop, entries with `entry.text === ""` remain in `parsed.system` unchanged.

```suggestion
    for (const entry of parsed.system) {
      if (
        entry.type === "text" &&
        typeof entry.text === "string" &&
        isBlocked(entry.text)
      ) {
        for (const blocked of BLOCKED_SYSTEM_STRINGS) {
          entry.text = entry.text.split(blocked).join("")
        }
      }
    }
    // Remove any entries that were reduced to empty strings by scrubbing.
    parsed.system = parsed.system.filter(
      (e) => !(e.type === "text" && e.text === ""),
    )
```

How can I resolve this? If you propose a fix, please make it concise.

Fix in Cursor Fix in Claude Code Fix in Codex

}
Expand Down
Loading