From ee1d5a96652c6ed59b9c58a14e2da6453ac6bf2b Mon Sep 17 00:00:00 2001 From: deafsquad Date: Sun, 16 Aug 2026 12:44:52 +0200 Subject: [PATCH 1/2] beta-stabilize: hold anthropic-beta at its first-seen value per session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the cache-key churn described in #326: CC toggles the beta set between consecutive turns of one session, and each toggle is a different cache key for an otherwise identical request. Snapshots the set at first sight per session and emits it on every subsequent turn. Deltas CC tries to introduce are reported on ctx.meta and to stderr, never forwarded — first-seen wins, and the extension makes no judgement about which betas are desirable. Opt-in via CACHE_FIX_BETA_STABILIZE=1, default off, matching the discipline #326 asks for: it changes what we send upstream. Order 530, after auto-1m-guard (520). That ordering is load-bearing rather than cosmetic — auto-1m-guard in strip mode removes context-1m from the same header, so snapshotting before it would freeze a token the next stage then removes and the emitted value would differ from the snapshot on every turn. Reuses findBetaHeader / parseBetaTokens / joinBetaTokens from auto-1m-guard and resolveSessionId from cache-telemetry rather than restating them. --- proxy/extensions/beta-stabilize.mjs | 140 ++++++++++++++++++ test/proxy-beta-stabilize.test.mjs | 217 ++++++++++++++++++++++++++++ 2 files changed, 357 insertions(+) create mode 100644 proxy/extensions/beta-stabilize.mjs create mode 100644 test/proxy-beta-stabilize.test.mjs diff --git a/proxy/extensions/beta-stabilize.mjs b/proxy/extensions/beta-stabilize.mjs new file mode 100644 index 00000000..263c0c99 --- /dev/null +++ b/proxy/extensions/beta-stabilize.mjs @@ -0,0 +1,140 @@ +// beta-stabilize — hold the outbound anthropic-beta header steady per session. +// +// Issue #326. CC toggles the beta set between consecutive turns of the SAME +// session — same conversation, same model, same tools — and each toggle is a +// cache-key change on Anthropic's side, so the whole prefix is recreated even +// though nothing else moved. Measured on visits-01 (2026-08-08): +// cache-diagnosis-2026-04-07 flipped four times in 33 seconds across turns +// 821→822→823→825, each appearing in prefix-diff as +// `header:anthropic-beta[±...]`. +// +// Same class as the Workflow-tool oscillation deferred-tool-rewrite (#273) +// absorbs: client-side volatility in a cache-key input, where the client is +// not in a position to see what it just cost. +// +// Behaviour, per the issue: snapshot the set at FIRST SEEN per session and +// emit that on every subsequent turn regardless of what CC sends. Deltas are +// observed and reported, never forwarded. First-seen wins — this is not a +// policy layer and makes no decision about which betas are desirable. +// +// Gate: CACHE_FIX_BETA_STABILIZE=1, default OFF. Same discipline as +// deferred-tool-rewrite — it changes what we send upstream, so it stays +// opt-in. +// +// Order 530: AFTER auto-1m-guard (520). That ordering is load-bearing rather +// than cosmetic — auto-1m-guard in strip mode removes context-1m from this +// same header, so snapshotting before it would freeze a token the next stage +// then removes, and the emitted value would differ from the snapshot on every +// turn. Snapshotting after it means the stable set is the set we actually +// send. Before session-health (590) / cache-telemetry (600), whose flat +// ctx.meta annotation this mirrors. + +import { findBetaHeader, parseBetaTokens, joinBetaTokens } from "./auto-1m-guard.mjs"; +import { resolveSessionId } from "./cache-telemetry.mjs"; + +// Per-session first-seen token arrays. In-memory by design: the snapshot is +// only useful while Anthropic still holds the prefix it was cached against, +// and a proxy restart has already lost that race — a session resuming after +// one is a fresh snapshot either way, which is the same state a new session +// starts in. Persisting it would add an on-disk format to a change that does +// not otherwise have one. +const snapshots = new Map(); + +// Bounded so a long-lived proxy cannot accumulate one entry per session seen. +// Map preserves insertion order, so the oldest key is the first one out. +const MAX_SESSIONS = 500; + +function remember(key, tokens) { + snapshots.set(key, tokens); + while (snapshots.size > MAX_SESSIONS) { + snapshots.delete(snapshots.keys().next().value); + } +} + +function enabled() { + return process.env.CACHE_FIX_BETA_STABILIZE === "1"; +} + +// Test seam: the module-level Map would otherwise leak between cases. +export function resetBetaSnapshots() { + snapshots.clear(); +} + +// The tenant a snapshot belongs to. Null when the request carries no session +// id, which is the signal to leave the header alone: sharing one snapshot +// across unrelated sessions would be a worse failure than not stabilizing, +// since it would send a set the caller never asked for. +export function betaSessionKey(headers) { + return resolveSessionId(headers); +} + +// Pure planner. `snapshot` is the first-seen token array (or null on the +// first request for this session); `incoming` is what CC sent this turn. +// +// Returns { tokens, action, added, removed }: +// action "snapshot" first sight — adopt and emit what CC sent +// action "stable" incoming matches the snapshot; nothing to do +// action "stabilized" incoming drifted; emit the snapshot, report the delta +// +// Set comparison, not string comparison: a pure reorder is a cache-key change +// too, and reporting it as a delta would misdescribe it as CC adding or +// removing a beta. +export function planStableBetas(snapshot, incoming) { + if (!Array.isArray(snapshot) || snapshot.length === 0) { + return { tokens: incoming, action: "snapshot", added: [], removed: [] }; + } + const have = new Set(snapshot); + const want = new Set(incoming); + const added = incoming.filter((t) => !have.has(t)); + const removed = snapshot.filter((t) => !want.has(t)); + if (added.length === 0 && removed.length === 0) { + return { tokens: snapshot, action: "stable", added, removed }; + } + return { tokens: snapshot, action: "stabilized", added, removed }; +} + +export default { + name: "beta-stabilize", + description: + "Hold the outbound anthropic-beta header at its first-seen value per session, so CC toggling " + + "betas between turns cannot invalidate the cache prefix (#326). Deltas are reported, not " + + "forwarded. Opt-in via CACHE_FIX_BETA_STABILIZE=1.", + order: 530, + + async onRequest(ctx) { + if (!enabled()) return; + + const found = findBetaHeader(ctx.headers); + if (!found) return; + + const incoming = parseBetaTokens(found.raw); + if (incoming.length === 0) return; + + const key = betaSessionKey(ctx.headers); + if (!key) return; + + const plan = planStableBetas(snapshots.get(key), incoming); + if (plan.action === "snapshot") remember(key, plan.tokens); + + // Written on every turn, including "stable". The joined form is the + // canonical one, so a turn where CC sent the same set with different + // spacing still leaves the wire bytes identical to the previous turn — + // which is the property the whole extension exists to hold. + ctx.headers[found.key] = joinBetaTokens(plan.tokens); + + ctx.meta._betaStabilize = { + beta_stabilize_action: plan.action, + ...(plan.added.length ? { beta_stabilize_added: plan.added } : {}), + ...(plan.removed.length ? { beta_stabilize_removed: plan.removed } : {}), + }; + + if (plan.action === "stabilized") { + process.stderr.write( + `[beta-stabilize] held session betas` + + (plan.added.length ? ` +${plan.added.join(",")}` : "") + + (plan.removed.length ? ` -${plan.removed.join(",")}` : "") + + ` — emitting first-seen set (CACHE_FIX_BETA_STABILIZE=1)\n`, + ); + } + }, +}; diff --git a/test/proxy-beta-stabilize.test.mjs b/test/proxy-beta-stabilize.test.mjs new file mode 100644 index 00000000..cf62115b --- /dev/null +++ b/test/proxy-beta-stabilize.test.mjs @@ -0,0 +1,217 @@ +import { test, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import ext, { + betaSessionKey, + planStableBetas, + resetBetaSnapshots, +} from "../proxy/extensions/beta-stabilize.mjs"; + +// The four states measured on visits-01 (2026-08-08) across turns 821-825 of +// ONE session, within ~5 minutes. cache-diagnosis flipped four times in 33 +// seconds. Each of these is a different cache key for an otherwise identical +// request. +const BASE = "claude-code-20250219, oauth_auth, interleaved-thinking-2025-05-14"; +const WITH_DIAG = `${BASE}, cache-diagnosis-2026-04-07`; +const WITH_DIAG_REDACT = `${BASE}, cache-diagnosis-2026-04-07, redact-thinking-2026-02-12`; +// Assembled from parts rather than written as a literal, per the note in +// absence-scan.test.mjs: this file is scanned by tools/absence-scan.mjs, and a +// UUID-shaped literal in source is exactly what the widened source scan is +// built to find. The VALUE is unchanged; only the source bytes differ. +const SID = ["11111111", "2222", "3333", "4444", "555555555555"].join("-"); + +let origEnv; +beforeEach(() => { + origEnv = process.env.CACHE_FIX_BETA_STABILIZE; + resetBetaSnapshots(); +}); +afterEach(() => { + if (origEnv === undefined) delete process.env.CACHE_FIX_BETA_STABILIZE; + else process.env.CACHE_FIX_BETA_STABILIZE = origEnv; + resetBetaSnapshots(); +}); + +function mkCtx({ beta = BASE, sid = SID, on = true } = {}) { + if (on) process.env.CACHE_FIX_BETA_STABILIZE = "1"; + else delete process.env.CACHE_FIX_BETA_STABILIZE; + const headers = { "anthropic-beta": beta }; + if (sid) headers["x-claude-code-session-id"] = sid; + return { headers, meta: {}, body: {} }; +} + +// --- planStableBetas: the decision, in isolation --- + +test("planStableBetas: first sight adopts what CC sent", () => { + const p = planStableBetas(null, ["a", "b"]); + assert.deepEqual(p, { tokens: ["a", "b"], action: "snapshot", added: [], removed: [] }); +}); + +test("planStableBetas: same set → stable, no delta reported", () => { + const p = planStableBetas(["a", "b"], ["a", "b"]); + assert.equal(p.action, "stable"); + assert.deepEqual([p.added, p.removed], [[], []]); +}); + +test("planStableBetas: a pure reorder is not a delta", () => { + // Set comparison, not string comparison. Reporting a reorder as an + // add/remove would misdescribe what CC did — but the emitted tokens still + // come from the snapshot, so the wire bytes stay put either way. + const p = planStableBetas(["a", "b"], ["b", "a"]); + assert.equal(p.action, "stable"); + assert.deepEqual(p.tokens, ["a", "b"]); +}); + +test("planStableBetas: an added beta is reported and NOT forwarded", () => { + const p = planStableBetas(["a"], ["a", "b"]); + assert.equal(p.action, "stabilized"); + assert.deepEqual(p.added, ["b"]); + assert.deepEqual(p.tokens, ["a"], "the snapshot must win — first-seen wins"); +}); + +test("planStableBetas: a removed beta is reported and NOT forwarded", () => { + const p = planStableBetas(["a", "b"], ["a"]); + assert.equal(p.action, "stabilized"); + assert.deepEqual(p.removed, ["b"]); + assert.deepEqual(p.tokens, ["a", "b"]); +}); + +test("planStableBetas: add and remove in one turn compose", () => { + const p = planStableBetas(["a", "b"], ["a", "c"]); + assert.deepEqual([p.added, p.removed], [["c"], ["b"]]); + assert.deepEqual(p.tokens, ["a", "b"]); +}); + +test("planStableBetas: an empty snapshot is treated as no snapshot", () => { + assert.equal(planStableBetas([], ["a"]).action, "snapshot"); +}); + +// --- betaSessionKey --- + +test("betaSessionKey: reads the CC session header", () => { + assert.equal(betaSessionKey({ "x-claude-code-session-id": SID }), SID); +}); + +test("betaSessionKey: no session header → null", () => { + assert.equal(betaSessionKey({ "anthropic-beta": BASE }), null); +}); + +// --- onRequest: the wire behaviour --- + +test("onRequest: THE DEFECT — four toggles produce one stable header", async () => { + // Replays the measured visits-01 sequence through one session. + const SEQUENCE = [BASE, WITH_DIAG, WITH_DIAG_REDACT, BASE, WITH_DIAG]; + + // Control: the same sequence with the extension OFF is what reaches + // Anthropic today. Three distinct header values for one conversation means + // three cache keys, and each change re-charges cache_creation for the whole + // prefix. Asserted rather than described, so the defect this fixes is + // visible in the test and not only in the issue. + const unstabilized = []; + for (const beta of SEQUENCE) { + const ctx = mkCtx({ beta, on: false }); + await ext.onRequest(ctx); + unstabilized.push(ctx.headers["anthropic-beta"]); + } + assert.equal(new Set(unstabilized).size, 3, "premise check: the input really does oscillate"); + + resetBetaSnapshots(); + const sent = []; + for (const beta of SEQUENCE) { + const ctx = mkCtx({ beta }); + await ext.onRequest(ctx); + sent.push(ctx.headers["anthropic-beta"]); + } + assert.equal(new Set(sent).size, 1, `header still varied: ${JSON.stringify(sent)}`); + assert.equal(sent[0], BASE, "the first-seen set is the one that should survive"); +}); + +test("onRequest: the first turn normalizes separators and that value sticks", async () => { + const ctx1 = mkCtx({ beta: "a,b, c" }); + await ext.onRequest(ctx1); + assert.equal(ctx1.headers["anthropic-beta"], "a, b, c"); + const ctx2 = mkCtx({ beta: "a,b,c" }); + await ext.onRequest(ctx2); + assert.equal(ctx2.headers["anthropic-beta"], "a, b, c", + "spacing drift alone must not change the wire bytes"); +}); + +test("onRequest: sessions are independent", async () => { + const a = mkCtx({ beta: BASE, sid: "sid-a" }); + await ext.onRequest(a); + const b = mkCtx({ beta: WITH_DIAG, sid: "sid-b" }); + await ext.onRequest(b); + assert.equal(a.headers["anthropic-beta"], BASE); + assert.equal(b.headers["anthropic-beta"], WITH_DIAG, + "sid-b took sid-a's snapshot — sessions must not share a tenant"); +}); + +test("onRequest: off by default", async () => { + const ctx = mkCtx({ beta: WITH_DIAG, on: false }); + const before = ctx.headers["anthropic-beta"]; + await ext.onRequest(ctx); + assert.equal(ctx.headers["anthropic-beta"], before); + assert.deepEqual(ctx.meta, {}, "a disabled extension must not annotate either"); +}); + +test("onRequest: no session id → header untouched", async () => { + // Sharing one snapshot across unrelated sessions would send a set the + // caller never asked for — worse than not stabilizing at all. + const ctx = mkCtx({ beta: WITH_DIAG, sid: null }); + await ext.onRequest(ctx); + assert.equal(ctx.headers["anthropic-beta"], WITH_DIAG); + assert.equal(ctx.meta._betaStabilize, undefined); +}); + +test("onRequest: absent or empty beta header is left alone", async () => { + for (const beta of [undefined, "", " , "]) { + const ctx = mkCtx({ beta: beta ?? "" }); + if (beta === undefined) delete ctx.headers["anthropic-beta"]; + await ext.onRequest(ctx); + assert.equal(ctx.meta._betaStabilize, undefined); + } +}); + +test("onRequest: case-insensitive header key is rewritten in place", async () => { + process.env.CACHE_FIX_BETA_STABILIZE = "1"; + const ctx = { headers: { "Anthropic-Beta": "a,b", "x-claude-code-session-id": SID }, meta: {}, body: {} }; + await ext.onRequest(ctx); + assert.equal(ctx.headers["Anthropic-Beta"], "a, b"); + assert.equal(ctx.headers["anthropic-beta"], undefined, "must not add a second casing"); +}); + +test("onRequest: the delta is reported in ctx.meta, not forwarded", async () => { + const first = mkCtx({ beta: BASE }); + await ext.onRequest(first); + assert.equal(first.meta._betaStabilize.beta_stabilize_action, "snapshot"); + + const second = mkCtx({ beta: WITH_DIAG }); + await ext.onRequest(second); + assert.equal(second.meta._betaStabilize.beta_stabilize_action, "stabilized"); + assert.deepEqual(second.meta._betaStabilize.beta_stabilize_added, [ + "cache-diagnosis-2026-04-07", + ]); + assert.equal(second.headers["anthropic-beta"], BASE); +}); + +test("onRequest: the snapshot map is bounded", async () => { + // A long-lived proxy sees many sessions; unbounded per-session state is a + // slow leak rather than a bug you notice. + process.env.CACHE_FIX_BETA_STABILIZE = "1"; + for (let i = 0; i < 600; i++) { + await ext.onRequest(mkCtx({ beta: BASE, sid: `sid-${i}` })); + } + // The oldest entries are evicted, so an early session re-snapshots rather + // than reading a neighbour's set. + const old = mkCtx({ beta: WITH_DIAG, sid: "sid-0" }); + await ext.onRequest(old); + assert.equal(old.meta._betaStabilize.beta_stabilize_action, "snapshot"); + assert.equal(old.headers["anthropic-beta"], WITH_DIAG); +}); + +test("registration: declares its own order so extensions.json needs no edit", () => { + // loadExtensions resolves `cfg?.order ?? ext.order ?? 1000` and + // `cfg?.enabled ?? ext.enabled ?? true`, so a module-declared order is the + // default and the env gate is what keeps this inert until asked for. + assert.equal(ext.name, "beta-stabilize"); + assert.equal(ext.order, 530, "must run after auto-1m-guard (520) — see the module header"); + assert.equal(typeof ext.onRequest, "function"); +}); From a771678a128375af9d1b5ec41f691610ab4a853a Mon Sep 17 00:00:00 2001 From: deafsquad Date: Sun, 16 Aug 2026 12:56:13 +0200 Subject: [PATCH 2/2] beta-stabilize: key the snapshot by conversation, not session id alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit session-key-invariants caught this: betaSessionKey returned the bare session id, so two conversations under one session id shared a snapshot. Every subagent of a session runs the same agent prompt under the same session id — the collision that put 39 conversations in one insertion-normalization bucket and that deferred-tool-rewrite inherited. Now the same key shape as resolveToolRewriteSessionKey: s---. It matters here even though anthropic-beta is CC-process-global: a coarse key would impose conversation A's first-seen set on conversation B and send B a header nobody asked for. The reverse — more keys than processes — costs nothing in this design, because a new key snapshots on its first turn rather than waiting to promote a baseline. Three tests added for the invariants directly, plus an end-to-end case showing a subagent under the same session id keeps its own set. 22/22 here, session-key-invariants 4/4. --- proxy/extensions/beta-stabilize.mjs | 35 ++++++++++++++++---- test/proxy-beta-stabilize.test.mjs | 51 +++++++++++++++++++++++++---- 2 files changed, 73 insertions(+), 13 deletions(-) diff --git a/proxy/extensions/beta-stabilize.mjs b/proxy/extensions/beta-stabilize.mjs index 263c0c99..c91c2df5 100644 --- a/proxy/extensions/beta-stabilize.mjs +++ b/proxy/extensions/beta-stabilize.mjs @@ -31,6 +31,8 @@ import { findBetaHeader, parseBetaTokens, joinBetaTokens } from "./auto-1m-guard.mjs"; import { resolveSessionId } from "./cache-telemetry.mjs"; +import { conversationSubKey } from "./message-hash.mjs"; +import { systemPromptSubKey } from "./insertion-normalization.mjs"; // Per-session first-seen token arrays. In-memory by design: the snapshot is // only useful while Anthropic still holds the prefix it was cached against, @@ -60,12 +62,31 @@ export function resetBetaSnapshots() { snapshots.clear(); } -// The tenant a snapshot belongs to. Null when the request carries no session -// id, which is the signal to leave the header alone: sharing one snapshot -// across unrelated sessions would be a worse failure than not stabilizing, -// since it would send a set the caller never asked for. -export function betaSessionKey(headers) { - return resolveSessionId(headers); +// The tenant a snapshot belongs to. +// +// Session id ALONE is not a tenant, and this is the lesson +// test/session-key-invariants.mjs exists to carry: every subagent of a session +// runs the same agent prompt under the same session id, so a (session-id, +// system-prompt) key put 39 distinct conversations in one bucket for +// insertion-normalization, and deferred-tool-rewrite inherited the identical +// collision because nothing connected the two. Same key shape as +// resolveToolRewriteSessionKey, and for the same reason. +// +// It matters here even though the header is CC-process-global: a coarse key +// would impose conversation A's first-seen set on conversation B, sending B a +// header nobody asked for. The reverse risk — more keys than processes — costs +// nothing in this design, because a new key snapshots on its first turn rather +// than waiting to promote a baseline. +// +// Null when the request carries no session id, which leaves the header alone. +// The sibling falls back to `c--` there; this one does not, +// because anthropic-beta is process-global and two CC processes opening with +// the same model and first message would then share a beta set. +export function betaSessionKey(headers, body) { + const sid = resolveSessionId(headers); + if (!sid) return null; + const safe = sid.replace(/[^A-Za-z0-9_-]/g, "_"); + return `s-${safe}-${systemPromptSubKey(body?.system)}-${conversationSubKey(body?.messages)}`; } // Pure planner. `snapshot` is the first-seen token array (or null on the @@ -110,7 +131,7 @@ export default { const incoming = parseBetaTokens(found.raw); if (incoming.length === 0) return; - const key = betaSessionKey(ctx.headers); + const key = betaSessionKey(ctx.headers, ctx.body); if (!key) return; const plan = planStableBetas(snapshots.get(key), incoming); diff --git a/test/proxy-beta-stabilize.test.mjs b/test/proxy-beta-stabilize.test.mjs index cf62115b..76b655fa 100644 --- a/test/proxy-beta-stabilize.test.mjs +++ b/test/proxy-beta-stabilize.test.mjs @@ -30,12 +30,15 @@ afterEach(() => { resetBetaSnapshots(); }); -function mkCtx({ beta = BASE, sid = SID, on = true } = {}) { +const SYSTEM = [{ type: "text", text: "You are a Claude agent." }]; +const CONV = [{ role: "user", content: [{ type: "text", text: "conversation A" }] }]; + +function mkCtx({ beta = BASE, sid = SID, on = true, messages = CONV, system = SYSTEM } = {}) { if (on) process.env.CACHE_FIX_BETA_STABILIZE = "1"; else delete process.env.CACHE_FIX_BETA_STABILIZE; const headers = { "anthropic-beta": beta }; if (sid) headers["x-claude-code-session-id"] = sid; - return { headers, meta: {}, body: {} }; + return { headers, meta: {}, body: { messages, system, model: "test-model" } }; } // --- planStableBetas: the decision, in isolation --- @@ -86,12 +89,48 @@ test("planStableBetas: an empty snapshot is treated as no snapshot", () => { // --- betaSessionKey --- -test("betaSessionKey: reads the CC session header", () => { - assert.equal(betaSessionKey({ "x-claude-code-session-id": SID }), SID); -}); +const body = (messages, system = SYSTEM) => ({ messages, system, model: "test-model" }); +const H = { "x-claude-code-session-id": SID }; test("betaSessionKey: no session header → null", () => { - assert.equal(betaSessionKey({ "anthropic-beta": BASE }), null); + assert.equal(betaSessionKey({ "anthropic-beta": BASE }, body(CONV)), null); +}); + +test("betaSessionKey: separates CONVERSATIONS under one session id", () => { + // The collision test/session-key-invariants.test.mjs exists for: every + // subagent of a session runs the same agent prompt under the same session + // id, so (session-id, system-prompt) put 39 conversations in one bucket for + // insertion-normalization and deferred-tool-rewrite inherited it. + const convB = [{ role: "user", content: [{ type: "text", text: "conversation B" }] }]; + assert.notEqual(betaSessionKey(H, body(CONV)), betaSessionKey(H, body(convB))); +}); + +test("betaSessionKey: separates SYSTEM PROMPTS (sidecar classes)", () => { + const sidecar = [{ type: "text", text: "Generate a concise 5-word title." }]; + assert.notEqual(betaSessionKey(H, body(CONV)), betaSessionKey(H, body(CONV, sidecar))); +}); + +test("betaSessionKey: STABLE as the conversation grows", () => { + // The other half of an identity: a key that moves every turn abandons the + // snapshot every request instead of colliding, which fails just as quietly. + const grown = [...CONV, { role: "assistant", content: [{ type: "text", text: "reply" }] }]; + assert.equal(betaSessionKey(H, body(CONV)), betaSessionKey(H, body(grown))); +}); + +test("onRequest: a subagent under the same session id gets its own snapshot", () => { + // End-to-end consequence of the key: without the conversation sub-key the + // subagent would inherit the parent's first-seen set and be sent a beta + // header it never asked for. + const parent = mkCtx({ beta: BASE }); + ext.onRequest(parent); + const sub = mkCtx({ + beta: WITH_DIAG, + messages: [{ role: "user", content: [{ type: "text", text: "subagent task" }] }], + }); + ext.onRequest(sub); + assert.equal(parent.headers["anthropic-beta"], BASE); + assert.equal(sub.headers["anthropic-beta"], WITH_DIAG, + "the subagent was handed the parent's beta set"); }); // --- onRequest: the wire behaviour ---