diff --git a/changelog.d/fixes/9505-atu-effort-beta-allowlist.md b/changelog.d/fixes/9505-atu-effort-beta-allowlist.md new file mode 100644 index 0000000000..e580e1cb6b --- /dev/null +++ b/changelog.d/fixes/9505-atu-effort-beta-allowlist.md @@ -0,0 +1 @@ +- fix(sse): stop force-injecting advanced-tool-use beta via the effort-2025-11-24 gate; forward client-negotiated effort through the allowlist (#9505) diff --git a/open-sse/config/anthropicHeaders.ts b/open-sse/config/anthropicHeaders.ts index 2edc489d1e..cf6710c4fe 100644 --- a/open-sse/config/anthropicHeaders.ts +++ b/open-sse/config/anthropicHeaders.ts @@ -57,6 +57,11 @@ export const FORWARDABLE_CLIENT_BETAS = Object.freeze([ "context-1m-2025-08-07", "code-execution-2025-08-25", "skills-2025-10-02", + // effort-2025-11-24 is a client-negotiated beta (Claude Code sends it on every + // request). selectBetaFlags no longer force-adds it as a side-effect of the ATU + // gate (#9505), so a client that sent it must keep it through the merge — + // otherwise its effort negotiation is silently dropped. + "effort-2025-11-24", ]); /** diff --git a/open-sse/executors/claudeIdentity.ts b/open-sse/executors/claudeIdentity.ts index c9544c6743..8fed8fa597 100644 --- a/open-sse/executors/claudeIdentity.ts +++ b/open-sse/executors/claudeIdentity.ts @@ -357,10 +357,12 @@ export function selectBetaFlags( // betas it actually asked for. Opaque clients (clientBetaSet === null) keep them all. const allowThinking = clientBetaSet === null || clientBetaSet.has("interleaved-thinking-2025-05-14"); + // effort-2025-11-24 must NOT imply advanced-tool-use-2025-11-20 (#9505): Claude + // Code sends effort on every request and never sends ATU, so treating effort as + // a proxy for ATU force-injects the heavy-agent pair the client never negotiated — + // the same class of mutation #3415 closed. Opaque clients keep the full set. const allowHeavy = - clientBetaSet === null || - clientBetaSet.has("advanced-tool-use-2025-11-20") || - clientBetaSet.has("effort-2025-11-24"); + clientBetaSet === null || clientBetaSet.has("advanced-tool-use-2025-11-20"); const hasSystem = !!b.system && (typeof b.system === "string" || (Array.isArray(b.system) && b.system.length > 0)); diff --git a/tests/unit/claude-atu-effort-leak-9505.test.ts b/tests/unit/claude-atu-effort-leak-9505.test.ts new file mode 100644 index 0000000000..f180f23217 --- /dev/null +++ b/tests/unit/claude-atu-effort-leak-9505.test.ts @@ -0,0 +1,74 @@ +// Regression guard for #9505 — forced advanced-tool-use beta must not survive +// the effort-2025-11-24 gate; client-negotiated effort must survive the merge. +import test from "node:test"; +import assert from "node:assert/strict"; + +const { selectBetaFlags } = await import( + "../../open-sse/executors/claudeIdentity.ts" +); +const { mergeClientAnthropicBeta } = await import( + "../../open-sse/config/anthropicHeaders.ts" +); + +function fullAgentBody(model: string) { + return { + model, + system: "You are a coding agent.", + tools: [ + { name: "read_file", description: "x", input_schema: { type: "object" } }, + ], + }; +} + +function pipeline(model: string, clientBeta: string | null) { + return mergeClientAnthropicBeta( + selectBetaFlags(fullAgentBody(model), null, clientBeta), + clientBeta + ); +} + +test("#9505 client sends effort but NOT advanced-tool-use -> ATU must NOT be forced", () => { + const out = pipeline("claude-opus-5", "claude-code-20250219,effort-2025-11-24"); + assert.ok( + !out.split(",").includes("advanced-tool-use-2025-11-20"), + "must NOT force ATU when client only sent effort" + ); +}); + +test("#9505 client sends effort only -> effort still survives the merge", () => { + const out = pipeline("claude-opus-5", "claude-code-20250219,effort-2025-11-24"); + assert.ok( + out.split(",").includes("effort-2025-11-24"), + "client-sent effort must survive the allowlist merge" + ); +}); + +test("#9505 client sends advanced-tool-use explicitly -> ATU preserved", () => { + const out = pipeline( + "claude-opus-5", + "claude-code-20250219,advanced-tool-use-2025-11-20" + ); + assert.ok( + out.split(",").includes("advanced-tool-use-2025-11-20"), + "must keep ATU when client requested it" + ); + assert.ok( + out.split(",").includes("effort-2025-11-24"), + "ATU+effort pair stays together when ATU requested" + ); +}); + +test("#9505 client sends BOTH effort and ATU -> both preserved", () => { + const out = pipeline( + "claude-sonnet-5", + "claude-code-20250219,advanced-tool-use-2025-11-20,effort-2025-11-24" + ); + assert.ok(out.split(",").includes("advanced-tool-use-2025-11-20")); + assert.ok(out.split(",").includes("effort-2025-11-24")); +}); + +test("#9505 opaque client (no clientBeta) still gets full heavy-agent set", () => { + const flags = selectBetaFlags(fullAgentBody("claude-opus-5")); + assert.ok(flags.includes("advanced-tool-use-2025-11-20")); + assert.ok(flags.includes("effort-2025-11-24")); +});