diff --git a/proxy/extensions/upstream-change-detection.mjs b/proxy/extensions/upstream-change-detection.mjs index 27ac7f65..e6b4675d 100644 --- a/proxy/extensions/upstream-change-detection.mjs +++ b/proxy/extensions/upstream-change-detection.mjs @@ -460,6 +460,17 @@ async function _processRequest(body, headers, { dir, map, fs }) { }; map.set(nsKey, updated); + // messages.count grows on every ordinary turn — a conversation living is + // not an upstream structural change, and logging it drowned the alarm + // file (97% of all recorded events were count-only diffs, measured + // 2026-07-30 over 4661 events). A count-only diff updates the stored + // fingerprint silently; any OTHER changed path still alarms, with the + // count change riding along in its diff if both moved. + const alarmDiff = diff.filter((d) => d.path !== "messages.count"); + if (alarmDiff.length === 0) { + return { event: "noop", nsKey }; + } + await appendEvent( { ts, diff --git a/test/proxy-upstream-change-detection.test.mjs b/test/proxy-upstream-change-detection.test.mjs index b4a2a218..ae124906 100644 --- a/test/proxy-upstream-change-detection.test.mjs +++ b/test/proxy-upstream-change-detection.test.mjs @@ -512,3 +512,62 @@ test("loadBaseline tolerates corrupt file", async () => { await rm(dir, { recursive: true, force: true }); } }); + +// --- 16. Count-only growth is not an upstream change --- + +test("16. a messages.count-only diff updates the baseline silently — no structural_change event", async () => { + const dir = await newTmp(); + process.env.CACHE_FIX_UPSTREAM_DETECTION = "1"; + process.env.CACHE_FIX_UPSTREAM_DIR = dir; + try { + const ext = await freshExt(); + await ext.default.onRequest({ body: makeBody() }); + // Same shape, one more ordinary message — a conversation growing. + await ext.default.onRequest({ + body: makeBody({ + messages: [ + { role: "user", content: [{ type: "text", text: "hello" }] }, + { role: "assistant", content: [{ type: "text", text: "hi" }] }, + { role: "user", content: [{ type: "text", text: "more" }] }, + ], + }), + }); + const text = await readFile(join(dir, "upstream-changes.jsonl"), "utf8"); + const events = text.split("\n").filter(Boolean).map((l) => JSON.parse(l).event); + assert.deepEqual(events, ["baseline_established"], "growth alone must not alarm"); + } finally { + delete process.env.CACHE_FIX_UPSTREAM_DETECTION; + delete process.env.CACHE_FIX_UPSTREAM_DIR; + await rm(dir, { recursive: true, force: true }); + } +}); + +test("17. count change RIDING a real structural change still alarms, count in the diff", async () => { + const dir = await newTmp(); + process.env.CACHE_FIX_UPSTREAM_DETECTION = "1"; + process.env.CACHE_FIX_UPSTREAM_DIR = dir; + try { + const ext = await freshExt(); + await ext.default.onRequest({ body: makeBody() }); + await ext.default.onRequest({ + body: makeBody({ + messages: [ + { role: "user", content: [{ type: "text", text: "hello" }] }, + { role: "assistant", content: [{ type: "text", text: "hi" }] }, + ], + tools: [ + { name: "Bash", input_schema: { properties: { command: {} } } }, + ], + }), + }); + const text = await readFile(join(dir, "upstream-changes.jsonl"), "utf8"); + const parsed = text.split("\n").filter(Boolean).map((l) => JSON.parse(l)); + const change = parsed.find((e) => e.event === "structural_change"); + assert.ok(change, "a tools change must still alarm"); + assert.ok(change.diff.some((d) => d.path === "messages.count"), "the count delta rides in the diff"); + } finally { + delete process.env.CACHE_FIX_UPSTREAM_DETECTION; + delete process.env.CACHE_FIX_UPSTREAM_DIR; + await rm(dir, { recursive: true, force: true }); + } +});