Skip to content
Merged
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
1 change: 1 addition & 0 deletions proxy/extensions.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"insertion-normalization": { "enabled": true, "order": 395 },
"cache-control-normalize": { "enabled": true, "order": 400 },
"messages-cache-breakpoint": { "enabled": true, "order": 410 },
"deferred-tool-rewrite": { "enabled": true, "order": 425 },
"ttl-management": { "enabled": true, "order": 500 },
"cache-telemetry": { "enabled": true, "order": 600 },
"overage-warning": { "enabled": true, "order": 610 },
Expand Down
675 changes: 675 additions & 0 deletions proxy/extensions/deferred-tool-rewrite.mjs

Large diffs are not rendered by default.

950 changes: 950 additions & 0 deletions test/deferred-tool-rewrite.test.mjs

Large diffs are not rendered by default.

30 changes: 30 additions & 0 deletions test/fixtures/toolgc-1536.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"_comment": "Synthetic fixture, minimal, built from the ledger SHAPE only (measured 2026-07-27 15:36, ledger row tools:REMOVE, CronCreate removed + DeferredToolPlaceholder reordered, no ToolSearch nearby; skills-update system events in-window) per docs/directives/robustness-threat-matrix.md row 13. Session mirrors were NOT read to build this — only the shape named in the matrix row (a known tool disappearing from tools[] plus an unrelated reorder, both mid-conversation, with no addition).",
"prior": {
"model": "claude-sonnet-4-6",
"system": [{ "type": "text", "text": "You are Claude Code, Anthropic's official CLI for Claude." }],
"messages": [
{ "role": "user", "content": [{ "type": "text", "text": "schedule a cron job" }] }
],
"tools": [
{ "name": "Read", "input_schema": { "type": "object", "properties": { "file_path": { "type": "string" } } } },
{ "name": "Bash", "input_schema": { "type": "object", "properties": { "command": { "type": "string" } } } },
{ "name": "CronCreate", "input_schema": { "type": "object", "properties": { "schedule": { "type": "string" } } } },
{ "name": "DeferredToolPlaceholder", "input_schema": { "type": "object", "properties": {} } }
]
},
"incoming": {
"model": "claude-sonnet-4-6",
"system": [{ "type": "text", "text": "You are Claude Code, Anthropic's official CLI for Claude." }],
"messages": [
{ "role": "user", "content": [{ "type": "text", "text": "schedule a cron job" }] },
{ "role": "assistant", "content": [{ "type": "text", "text": "Scheduled." }] },
{ "role": "user", "content": [{ "type": "text", "text": "thanks, what else can you do" }] }
],
"tools": [
{ "name": "DeferredToolPlaceholder", "input_schema": { "type": "object", "properties": {} } },
{ "name": "Read", "input_schema": { "type": "object", "properties": { "file_path": { "type": "string" } } } },
{ "name": "Bash", "input_schema": { "type": "object", "properties": { "command": { "type": "string" } } } }
]
}
}
35 changes: 35 additions & 0 deletions test/fixtures/toolload-1247.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"_comment": "Synthetic fixture, minimal, built from the ledger SHAPE only (measured 2026-07-27 12:47:56, ledger row tools[SendMessage:added], toolsMatch:false) per docs/directives/proxy-deferred-tool-rewrite.md Phase A. Session mirrors were NOT read to build this — only the shape named in the directive (a tools[] array gaining exactly one new entry, SendMessage, mid-conversation).",
"prior": {
"model": "claude-sonnet-4-6",
"system": [{ "type": "text", "text": "You are Claude Code, Anthropic's official CLI for Claude." }],
"messages": [
{ "role": "user", "content": [{ "type": "text", "text": "start a background agent" }] }
],
"tools": [
{ "name": "Read", "input_schema": { "type": "object", "properties": { "file_path": { "type": "string" } } } },
{ "name": "Bash", "input_schema": { "type": "object", "properties": { "command": { "type": "string" } } } }
]
},
"incoming": {
"model": "claude-sonnet-4-6",
"system": [{ "type": "text", "text": "You are Claude Code, Anthropic's official CLI for Claude." }],
"messages": [
{ "role": "user", "content": [{ "type": "text", "text": "start a background agent" }] },
{ "role": "assistant", "content": [{ "type": "text", "text": "Starting a background agent now." }] },
{ "role": "user", "content": [{ "type": "text", "text": "ok, keep going" }] }
],
"tools": [
{ "name": "Read", "input_schema": { "type": "object", "properties": { "file_path": { "type": "string" } } } },
{ "name": "Bash", "input_schema": { "type": "object", "properties": { "command": { "type": "string" } } } },
{
"name": "SendMessage",
"description": "Send a message to a teammate agent.",
"input_schema": {
"type": "object",
"properties": { "to": { "type": "string" }, "message": { "type": "string" } }
}
}
]
}
}
127 changes: 127 additions & 0 deletions test/session-key-invariants.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// Cross-extension session-key invariants — the guard against "the lesson did
// not travel to the sibling".
//
// This exact failure happened twice in one day (2026-07-28), the second time
// costing real cache:
//
// insertion-normalization keyed persisted state on (session-id,
// system-prompt). Every subagent of a session runs the same agent prompt,
// so one bucket held 39 distinct conversations and 100% of conversation
// switches within a bucket reset (60/60). Fixed by adding a conversation
// sub-key: 0 resets across 940 requests.
//
// deferred-tool-rewrite had the IDENTICAL key and did not get the fix,
// because nothing connected the two. Its tool_addition announcement is
// anchored to a MESSAGE IDENTITY, so under a shared key the stored anchor
// belonged to another conversation's history, failed to match, and
// re-anchored to "after the last user message" — a different index every
// request. Measured: our output diverging at index 4 while CC's history was
// byte-identical through index 23, twice in one corpus.
//
// A fix applied to one consumer of a shared idea is not applied. So this file
// does not test a list someone maintains: it DISCOVERS every exported
// `*SessionKey` function under proxy/extensions/ and holds all of them to the
// same invariants. A new stateful extension is covered the moment it exports
// one, and an existing one cannot quietly regress.
//
// If a future extension legitimately needs a coarser key, this test failing is
// the conversation about it — which is the point.

import { test } from "node:test";
import assert from "node:assert/strict";
import { readdir } from "node:fs/promises";
import { join, dirname } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";

const EXT_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "proxy", "extensions");

// prefix-diff is exempt, and the exemption is checked rather than trusted.
// It keeps its FILE key at the session id deliberately (its design note 1: a
// path that moves with content misses its own baseline, so a bust never gets
// logged) and separates co-tenants INSIDE the file via tenantId. It also
// shapes no request — it is telemetry, so a coarse key costs attribution
// precision, not cache. The exemption is paired with an assertion that
// tenantId still exists, so if that design ever changes this guard notices
// instead of staying quietly satisfied.
const SEPARATES_INSIDE_THE_FILE = new Set(["prefix-diff.mjs"]);

async function discoverKeyResolvers({ all = false } = {}) {
const found = [];
for (const f of (await readdir(EXT_DIR)).sort()) {
if (!f.endsWith(".mjs")) continue;
if (!all && SEPARATES_INSIDE_THE_FILE.has(f)) continue;
const mod = await import(pathToFileURL(join(EXT_DIR, f)).href);
for (const [name, fn] of Object.entries(mod)) {
if (typeof fn === "function" && /SessionKey$/.test(name)) {
found.push({ file: f, name, fn });
}
}
}
return found;
}

const HEADERS = { "x-claude-code-session-id": "shared-session" };
const SYSTEM = [{ type: "text", text: "You are a Claude agent." }];
const convA = [{ role: "user", content: [{ type: "text", text: "conversation A" }] }];
const convB = [{ role: "user", content: [{ type: "text", text: "conversation B" }] }];

// The resolvers do not share a signature — insertion-normalization takes
// (headers, messages, system), deferred-tool-rewrite takes (headers, body).
// ARITY distinguishes them mechanically, so no name list is maintained here:
// a name list is the same hand-maintained roster this file exists to avoid.
function callResolver(fn, { messages, system }) {
return fn.length >= 3
? fn(HEADERS, messages, system)
: fn(HEADERS, { messages, system, model: "test-model" });
}

test("every extension exporting a *SessionKey is discovered", async () => {
const resolvers = await discoverKeyResolvers();
assert.ok(resolvers.length >= 2, `expected at least the two stateful extensions, found ${resolvers.length}`);
const files = new Set(resolvers.map((r) => r.file));
// These two are the reason the file exists; losing either from discovery
// would silently empty the guard.
assert.ok(files.has("insertion-normalization.mjs"), [...files].join(","));
assert.ok(files.has("deferred-tool-rewrite.mjs"), [...files].join(","));
});

test("BITE — a session key must separate CONVERSATIONS, not just system prompts", async () => {
for (const { file, name, fn } of await discoverKeyResolvers()) {
const a = callResolver(fn, { messages: convA, system: SYSTEM });
const b = callResolver(fn, { messages: convB, system: SYSTEM });
assert.notEqual(
a,
b,
`${file}:${name} gives one key to two conversations under the same session-id and system prompt — ` +
`the collision that cost cache in deferred-tool-rewrite. Add conversationSubKey from message-hash.mjs.`,
);
}
});

test("a session key must separate SYSTEM PROMPTS (sidecar classes)", async () => {
for (const { file, name, fn } of await discoverKeyResolvers()) {
const main = callResolver(fn, { messages: convA, system: SYSTEM });
const sidecar = callResolver(fn, {
messages: convA,
system: [{ type: "text", text: "Generate a concise 5-word title." }],
});
assert.notEqual(main, sidecar, `${file}:${name} shares a key across system-prompt classes`);
}
});

test("a session key is STABLE for the same conversation as it grows", async () => {
// The other half: a key that changes every turn is not an identity either,
// and would abandon state on every request rather than colliding.
for (const { file, name, fn } of await discoverKeyResolvers()) {
const first = callResolver(fn, { messages: convA, system: SYSTEM });
const grown = callResolver(fn, {
messages: [...convA, { role: "assistant", content: [{ type: "text", text: "reply" }] }],
system: SYSTEM,
});
assert.equal(first, grown, `${file}:${name} changes key as the conversation grows — state cannot persist`);
}
});

// (A further case verifying prefix-diff's exemption from this invariant --
// its own tenantId separation -- ships with the prefix-diff changes, which
// export the function it inspects.)
148 changes: 148 additions & 0 deletions tools/probe-tool-addition.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
#!/usr/bin/env node
// probe-tool-addition — measure, per model, whether the API accepts the
// mid-conversation-tool-changes contract (tool_addition blocks).
//
// Exists because the allowlist in deferred-tool-rewrite.mjs is opt-in with
// evidence required, and the evidence was collected the expensive way once:
// the extension announced additions to every model, and on 2026-07-28 a
// sonnet-5 dispatch died with
//
// API Error: 400 tool_addition/tool_removal is not supported on this model
//
// after which TOOL_ADDITION_MODELS was cut to the one model with wire
// evidence. This script is the cheap way: one minimal real request per model,
// same auth path production uses (the CC OAuth credentials the proxy keeps
// fresh), wire shapes IMPORTED from the extension rather than re-typed here —
// a probe that hand-rolls the shape tests the probe author's memory, not the
// contract (the identity-key lesson, again).
//
// Direct to the API, not through the proxy — deliberately. The question is a
// property of the API endpoint per model, and the proxy would wrap the probe
// in session state, capture records and telemetry that all describe traffic
// no session sent. The directive's "one live request through the proxy"
// acceptance step remains what it is: end-to-end validation of the EXTENSION,
// done once per gate flip. This measures the MODEL support matrix.
//
// Three answers per model, never two:
// ACCEPTED — HTTP 200 with the addition block on the wire
// REJECTED — HTTP 400 naming tool_addition/tool_removal
// COULD NOT VERIFY — anything else (auth failure, rate limit, network,
// unrelated 400); reported verbatim, never classified, and the
// process exits non-zero so a broken probe cannot read as a
// clean sweep.
//
// KNOWN LIMIT (measured 2026-07-29): on a subscription OAuth token, this
// direct-API probe gets HTTP 429 for EVERY big model (opus, sonnet, fable)
// regardless of quota state — hand-built requests are refused for those
// models; only haiku answers, because CC itself sends it free-form utility
// traffic. For big models the working probe is a real session: start a
// throwaway proxy with CACHE_FIX_TOOL_ADDITION_EXTRA=<model> on a spare
// port, run `claude --model <model> -p` through it with a prompt that loads
// a tool via ToolSearch, then verify on production's capture that the
// injected block was forwarded byte-identically (replay the pipeline,
// compare against the outcome record's outSha) and that an outcome record
// exists (only written on a streamed 200). That is how fable-5 was measured.
//
// An ACCEPTED verdict is the evidence an allowlist entry cites (prefix +
// probe date); nothing is edited automatically.

import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { homedir } from "node:os";

import {
buildToolAdditionMessage,
injectAdditions,
forwardedTools,
anchorHash,
addBetaToken,
} from "../proxy/extensions/deferred-tool-rewrite.mjs";

const API = "https://api.anthropic.com/v1/messages";

// The current Claude lineup as CC sends it. Override: pass model ids as argv.
const DEFAULT_MODELS = [
"claude-opus-5",
"claude-fable-5",
"claude-sonnet-5",
"claude-haiku-4-5-20251001",
];

async function accessToken() {
const raw = await readFile(join(homedir(), ".claude", ".credentials.json"), "utf-8");
const c = JSON.parse(raw);
const o = c.claudeAiOauth ?? c;
if (!o.accessToken) throw new Error(".credentials.json carries no accessToken");
if (o.expiresAt && o.expiresAt < Date.now()) {
throw new Error("access token expired — start a Claude Code session to refresh it");
}
return o.accessToken;
}

function probeBody(model) {
// Two tools: one present from the start, one "added mid-conversation" via
// the real builders. forwardedTools marks the added one defer_loading; the
// addition message is injected at its anchor exactly as onRequest does.
const toolA = {
name: "echo_base",
description: "Echo the input string.",
input_schema: { type: "object", properties: { s: { type: "string" } }, required: ["s"] },
};
const toolB = {
name: "echo_added",
description: "Echo the input string (added mid-conversation).",
input_schema: { type: "object", properties: { s: { type: "string" } }, required: ["s"] },
};
const user = { role: "user", content: "Reply with the single word ok. Do not use tools." };
const additions = [
{
names: [toolB.name],
anchorHash: anchorHash(user),
message: buildToolAdditionMessage([toolB.name]),
},
];
const { messages } = injectAdditions([user], additions);
return {
model,
max_tokens: 16,
messages,
tools: forwardedTools([toolA, toolB], additions),
};
}

async function probe(model, token) {
const headers = {
"content-type": "application/json",
"anthropic-version": "2023-06-01",
"anthropic-beta": "oauth-2025-04-20",
authorization: `Bearer ${token}`,
};
addBetaToken(headers); // the same token the extension puts on real traffic
let res, text;
try {
res = await fetch(API, { method: "POST", headers, body: JSON.stringify(probeBody(model)) });
text = await res.text();
} catch (e) {
return { model, verdict: "COULD NOT VERIFY", detail: `network: ${e?.message ?? e}` };
}
if (res.status === 200) return { model, verdict: "ACCEPTED", detail: "HTTP 200" };
if (res.status === 400 && /tool_addition|tool_removal/.test(text)) {
return { model, verdict: "REJECTED", detail: text.slice(0, 200) };
}
return { model, verdict: "COULD NOT VERIFY", detail: `HTTP ${res.status}: ${text.slice(0, 300)}` };
}

const models = process.argv.slice(2).length ? process.argv.slice(2) : DEFAULT_MODELS;
const token = await accessToken();
let unverified = 0;
console.log(`probing ${models.length} model(s) against ${API}\n`);
for (const m of models) {
const r = await probe(m, token);
if (r.verdict === "COULD NOT VERIFY") unverified++;
console.log(`${r.verdict.padEnd(18)} ${m}`);
if (r.verdict !== "ACCEPTED") console.log(` ${r.detail}\n`);
}
if (unverified) {
console.error(`\n${unverified} model(s) COULD NOT be verified — that is not a verdict either way.`);
process.exit(1);
}
Loading