Skip to content
Closed
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
72 changes: 72 additions & 0 deletions src/__tests__/transform-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import { droidAdapter } from "../proxy/adapters/droid"
import { piAdapter } from "../proxy/adapters/pi"
import { forgeCodeAdapter } from "../proxy/adapters/forgecode"
import { passthroughAdapter } from "../proxy/adapters/passthrough"
import { claudeCodeTransforms } from "../proxy/transforms/claudecode"
import { claudeCodeAdapter } from "../proxy/adapters/claudecode"
import { getAdapterTransforms } from "../proxy/transforms/registry"

function makeCtx(adapter: string, body: any = {}) {
return createRequestContext({
Expand Down Expand Up @@ -229,3 +232,72 @@ describe("Passthrough (LiteLLM) transform parity", () => {
expect([...ctx.blockedTools]).toEqual([...passthroughAdapter.getBlockedBuiltinTools()])
})
})

// The Claude Code CLI drives its own tool loop. If registry.ts has no entry for
// adapter "claude-code" (or the transform's `adapters` array omits it), the SDK
// runs the client's task internally on the proxy host with built-ins unblocked
// — every Bash/Edit executes twice, once per side. Same class as #546.
describe("Claude Code transform parity", () => {
let savedMP: string | undefined
let savedCP: string | undefined
beforeEach(() => {
savedMP = process.env.MERIDIAN_PASSTHROUGH
savedCP = process.env.CLAUDE_PROXY_PASSTHROUGH
delete process.env.MERIDIAN_PASSTHROUGH
delete process.env.CLAUDE_PROXY_PASSTHROUGH
})
afterEach(() => {
if (savedMP !== undefined) process.env.MERIDIAN_PASSTHROUGH = savedMP
else delete process.env.MERIDIAN_PASSTHROUGH
if (savedCP !== undefined) process.env.CLAUDE_PROXY_PASSTHROUGH = savedCP
else delete process.env.CLAUDE_PROXY_PASSTHROUGH
})

it("is registered under the adapter's own name", () => {
expect(getAdapterTransforms(claudeCodeAdapter.name)).toBe(claudeCodeTransforms)
})

it("matches blockedTools", () => {
const ctx = runTransformHook(claudeCodeTransforms, "onRequest", makeCtx("claude-code"), "claude-code")
expect([...ctx.blockedTools]).toEqual([...claudeCodeAdapter.getBlockedBuiltinTools()])
expect(ctx.blockedTools.length).toBeGreaterThan(0)
})

it("matches incompatibleTools and allowedMcpTools", () => {
const ctx = runTransformHook(claudeCodeTransforms, "onRequest", makeCtx("claude-code"), "claude-code")
expect([...ctx.incompatibleTools]).toEqual([...claudeCodeAdapter.getAgentIncompatibleTools()])
expect([...ctx.allowedMcpTools]).toEqual([...claudeCodeAdapter.getAllowedMcpTools()])
})

it("matches coreToolNames (PascalCase, not OpenCode's lowercase)", () => {
const ctx = runTransformHook(claudeCodeTransforms, "onRequest", makeCtx("claude-code"), "claude-code")
expect([...ctx.coreToolNames!]).toEqual([...claudeCodeAdapter.getCoreToolNames!()])
})

it("matches passthrough (default on)", () => {
const ctx = runTransformHook(claudeCodeTransforms, "onRequest", makeCtx("claude-code"), "claude-code")
expect(ctx.passthrough).toBe(claudeCodeAdapter.usesPassthrough!())
expect(ctx.passthrough).toBe(true)
})

it("matches passthrough (env off → false)", () => {
process.env.MERIDIAN_PASSTHROUGH = "0"
const ctx = runTransformHook(claudeCodeTransforms, "onRequest", makeCtx("claude-code"), "claude-code")
expect(ctx.passthrough).toBe(claudeCodeAdapter.usesPassthrough!())
expect(ctx.passthrough).toBe(false)
})

it("matches supportsThinking and shouldTrackFileChanges", () => {
const ctx = runTransformHook(claudeCodeTransforms, "onRequest", makeCtx("claude-code"), "claude-code")
expect(ctx.supportsThinking).toBe(claudeCodeAdapter.supportsThinking!())
expect(ctx.shouldTrackFileChanges).toBe(claudeCodeAdapter.shouldTrackFileChanges!())
})

it("matches file change extraction for Edit and Bash", () => {
const ctx = runTransformHook(claudeCodeTransforms, "onRequest", makeCtx("claude-code"), "claude-code")
expect(ctx.extractFileChangesFromToolUse!("Edit", { file_path: "/a.ts" }))
.toEqual(claudeCodeAdapter.extractFileChangesFromToolUse!("Edit", { file_path: "/a.ts" }))
expect(ctx.extractFileChangesFromToolUse!("Bash", { command: "echo hi > /tmp/a" }))
.toEqual(claudeCodeAdapter.extractFileChangesFromToolUse!("Bash", { command: "echo hi > /tmp/a" }))
})
})
58 changes: 58 additions & 0 deletions src/proxy/transforms/claudecode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { Transform, RequestContext } from "../transform"
import { extractFileChangesFromBash, type FileChange } from "../fileChanges"
import { BLOCKED_BUILTIN_TOOLS, CLAUDE_CODE_ONLY_TOOLS, ALLOWED_MCP_TOOLS } from "../tools"
import { resolvePassthrough } from "../../env"

/**
* Claude Code transform — supplies the SDK tool config at request time.
*
* server.ts reads `pipelineCtx.*`, never the adapter methods, so without an
* entry here a Claude Code client gets the createRequestContext defaults:
* `blockedTools: []` and `passthrough: undefined`. That means the SDK
* subprocess runs the client's task with its own built-in Read/Write/Bash on
* the proxy host while the client executes the same tool calls locally —
* every side effect happens twice (#546, same failure mode as OpenCode's).
*
* Values mirror claudeCodeAdapter (adapters/claudecode.ts); the parity tests
* hold the two in sync. Core tool names are PascalCase here — Claude Code's
* toolkit is Read/Write/Edit/Bash/Glob/Grep, not OpenCode's lowercase names.
*/
export const claudeCodeTransforms: Transform[] = [
{
name: "claudecode-core",
adapters: ["claude-code"],

onRequest(ctx: RequestContext): RequestContext {
const extractFileChangesFromToolUse = (toolName: string, toolInput: unknown): FileChange[] => {
const input = toolInput as Record<string, unknown> | null | undefined
const filePath = input?.file_path ?? input?.filePath ?? input?.path
const lowerName = toolName.toLowerCase()
if (lowerName === "write" && filePath) {
return [{ operation: "wrote", path: String(filePath) }]
}
if ((lowerName === "edit" || lowerName === "multiedit") && filePath) {
return [{ operation: "edited", path: String(filePath) }]
}
if (lowerName === "bash" && input?.command) {
return extractFileChangesFromBash(String(input.command))
}
return []
}

return {
...ctx,
blockedTools: BLOCKED_BUILTIN_TOOLS,
incompatibleTools: CLAUDE_CODE_ONLY_TOOLS,
allowedMcpTools: ALLOWED_MCP_TOOLS,
coreToolNames: ["Read", "Write", "Edit", "Bash", "Glob", "Grep"],
// Claude Code owns tool execution client-side. Mirrors
// claudeCodeAdapter.usesPassthrough().
passthrough: resolvePassthrough(true),
supportsThinking: true,
// Claude Code surfaces its own file edits; don't duplicate them.
shouldTrackFileChanges: false,
extractFileChangesFromToolUse,
}
},
},
]
5 changes: 5 additions & 0 deletions src/proxy/transforms/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { forgeCodeTransforms } from "./forgecode"
import { passthroughTransforms } from "./passthrough"
import { cherryTransforms } from "./cherry"
import { codexTransforms } from "./codex"
import { claudeCodeTransforms } from "./claudecode"

const ADAPTER_TRANSFORMS: Record<string, readonly Transform[]> = {
opencode: openCodeTransforms,
Expand All @@ -16,6 +17,10 @@ const ADAPTER_TRANSFORMS: Record<string, readonly Transform[]> = {
forgecode: forgeCodeTransforms,
passthrough: passthroughTransforms,
cherry: cherryTransforms,
// Keyed by adapter.name ("claude-code", not "claudecode"). Without this the
// Claude Code CLI falls through to the empty default — built-ins unblocked,
// passthrough off — and the SDK re-executes every tool call on the proxy host.
"claude-code": claudeCodeTransforms,
// The OpenAI-compatible endpoint reuses OpenCode's transforms verbatim so
// tool/passthrough behaviour is identical; only the preset default differs
// (see sdkFeatures.ADAPTER_DEFAULTS.openai).
Expand Down