Skip to content

kernel authorization request: signature-only thinking blocks share one message id, so after a Claude Code compact new thinking blocks take over old folds #5180

Open

Description

@JordyDH

Description *

This is a bug report and a kernel-change authorization request (Kernel Boundary). Message identity is minted in kernel/src/wire/. The host only sees the result, so it cannot tell these blocks apart.

The bug

  • anthropicToCore derives a thinking block's id from deriveCanonicalMessageId("assistant", "reasoning", b.thinking).
  • Claude Code with Opus 5.x sends every thinking block with an empty thinking and only a signature. Every thinking block in the conversation therefore hashes to the same base id, h_2446cf3e209693e2 (the seed assistant|reasoning|||). They are told apart only by the ClusterCounter _N ordinal of their position.
  • redacted_thinking blocks share one id the same way, through CANONICAL_REDACTED_THINKING_TEXT.

Position ordinals are stable while the history only grows. When Claude Code compacts, its history starts over and so do the ordinals. New thinking blocks get h_2446cf3e209693e2_0, _1, …, which are the ids, and so the refs and fold coverage, of thinking blocks folded before the compact.

This breaks "Message Ids Are Never Reused" (AGENTS.md, #494). It is the same class of bug as #3751 was for images.

What we saw

One Claude Code session, Opus 5.5 with a 1M window, 2026-10-29. The id above had 535 instances in the session. After a Claude Code compact:

  • 8 folds from before the compact stayed active, and their summaries stayed in the context.
  • 99 of the 119 thinking blocks written after the compact counted as folded, so bili left them out of the request. That gave 107 thinking-pair-violated warnings (log below).
  • Refs such as m00282 came back on new messages. That cut what is compressible into 16 small pieces. The model's compress calls were then refused ("coverage regression") or rejected. The failed-only round that followed ended the turn, which is how we found #.

Proposed fix (patch below)

It uses the same seed slot that #3751/#3767 added for images:

  • A thinking block with empty text gets a hash of its signature in its id seed.
  • A redacted_thinking block gets a hash of its data.
  • Both payloads are unique per block, and the client sends them back unchanged.
  • Thinking blocks that do carry text keep the id they always had.
  • Nothing on the wire changes: the text and the signature are untouched.
  • Kernel version: 0.0.170 → 0.0.171.

Upgrade

The ids of signature-only thinking blocks change once, on upgrade. For a session saved before the upgrade, the folds still hold: the order-centric reconcile (KDD #11) carries their coverage over to the new ids by position. A test covers this, using a test-only switch, __setLegacyThinkingIdsForTests, that mints the old ids.

Open, not fixed by this patch

A session that was already hit, meaning folded and then compacted on an old build, is not healed by the upgrade. The reconcile carries the stale coverage over to the new ids by position, so the old summaries stay in the context and the thinking blocks they took stay out.

The patch stops new cases only. The test for the healed case is included, marked todo. I have not designed a fix for it.

The patch also covers thinking blocks only. Other ids are built from content and position too, and tool results deliberately leave out the tool_use_id (#3642). So any message that repeats content folded before a compact can be taken by the old fold. In the same session, after the compact, a pre-compact fold held a new Bash result whose whole text was ok, along with Claude Code's repeated reminder lines. Thinking blocks are only the most common case, because all of them are identical.

Not covered

Google thought parts with empty text, and unknown top-level Anthropic blocks such as documents, still share one id per kind.

Tests

  • kernel/tests/thinking-payload-identity.test.ts: the id rules. 2 of its 5 tests fail on master; the other 3 must pass either way.
  • tests/thinking-identity-after-compact.test.ts: end to end through the proxy, against a fake upstream that enforces the Anthropic wire ledger.
    • A fold over signature-only thinking holds on the next request.
    • The same holds for a session folded under the old ids.
    • After a simulated Claude Code compact, every new thinking block is sent, the old summary stays out, and no ref is reused. This test fails on master.
    • The todo heal case.

I ran the tests on a fork: master 885b9480, with #4715 and #4898 taken early, plus a few unrelated local patches. There, typecheck, the kernel suite (1512/1512) and the full suite pass, apart from 6 tests that also fail on unmodified master on this machine (#3157 ×2, mcp stdio shell ×2, corrupt index.db quarantine ×2). I did not run them on 09a5b5b7 itself. The kernel files touched are the same on both, and the patch applies cleanly to 09a5b5b7.

Possibly related: #5149, which saw stale refs after a native /compact. Its mechanism is different (two sessions sharing one id), but a compact with signature-only thinking would add to it.

bili version *

0.1.208, then 0.1.209, on a private fork with unrelated patches. The code involved is the same on master 09a5b5b7.

Usage mode *

Launcher (bili claude)

Client and version *

Claude Code 2.1.312

Operating system *

Debian 13 (Linux 6.12)

Upstream and model *

api.anthropic.com · claude-opus-5-5 · 1M

Compression-related config (optional)

models: claude-opus-5-5 declared with context 1000000
nudgeGrowthTokens: 150000

Log excerpt (strongly recommended)

2026-10-29T16:34:56.958 [warn] [sess=<session>] [v=0.1.208] thinking-pair-violated: 1 tool_use block(s) lost their inbound thinking block in the outbound rebuild — a stripped signature pair is rejected by the API; first seen: toolu_…
2026-10-29T16:35:26.183 [warn] [sess=<session>] [v=0.1.208] thinking-pair-violated: 2 tool_use block(s) lost their inbound thinking block in the outbound rebuild — …
…
2026-10-29T16:54:46.245 [warn] [sess=<session>] [v=0.1.208] thinking-pair-violated: 18 tool_use block(s) lost their inbound thinking block in the outbound rebuild — …
2026-10-29T19:18:49.309 [info] [sess=<session>] [v=0.1.208] [acp-proxy: compress REFUSED m02531–m02737, m00282–m02763, m02276–m02813, m02815–m02994 — coverage regression: 15 previously-folded message(s) would lose ALL covering block(s) …
Patch (applies to master 09a5b5b)
diff --git a/kernel/package-lock.json b/kernel/package-lock.json
index bbe88cba..9a50787c 100644
--- a/kernel/package-lock.json
+++ b/kernel/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "acp-kernel",
-  "version": "0.0.170",
+  "version": "0.0.171",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "acp-kernel",
-      "version": "0.0.170",
+      "version": "0.0.171",
       "license": "MIT",
       "devDependencies": {
         "@types/node": "^22.0.0",
diff --git a/kernel/package.json b/kernel/package.json
index 28b2aac6..37f51d6a 100644
--- a/kernel/package.json
+++ b/kernel/package.json
@@ -1,6 +1,6 @@
 {
   "name": "acp-kernel",
-  "version": "0.0.170",
+  "version": "0.0.171",
   "description": "Framework-agnostic context-compression engine (model-driven, 3-tier LSM). Pure core: no host dependency.",
   "license": "MIT",
   "author": "ranxianglei",
diff --git a/kernel/src/wire/anthropic.ts b/kernel/src/wire/anthropic.ts
index 0af98bd7..d801dd90 100644
--- a/kernel/src/wire/anthropic.ts
+++ b/kernel/src/wire/anthropic.ts
@@ -5,6 +5,7 @@ import {
   canonicalToolArgs,
   deriveCanonicalMessageId,
   imageMediaFingerprint,
+  thinkingPayloadFingerprint,
 } from "./identity.js";
 import { hashId } from "./util.js";
 import { ClusterCounter } from "./message-id.js";
@@ -202,6 +203,7 @@ export function anthropicToCore(body: AnthropicRequestBody): Flat {
             "assistant",
             "reasoning",
             b.thinking,
+            { payloadFingerprint: thinkingPayloadFingerprint(b.thinking, b.signature) },
           );
           msgs.push({
             id: clusters.next(base),
@@ -261,6 +263,7 @@ export function anthropicToCore(body: AnthropicRequestBody): Flat {
             m.role,
             "text",
             CANONICAL_REDACTED_THINKING_TEXT,
+            { payloadFingerprint: thinkingPayloadFingerprint("", b.data) },
           );
           msgs.push({
             id: clusters.next(base),
diff --git a/kernel/src/wire/identity.ts b/kernel/src/wire/identity.ts
index 6f9da6d3..c97aee69 100644
--- a/kernel/src/wire/identity.ts
+++ b/kernel/src/wire/identity.ts
@@ -93,14 +93,36 @@ export function deriveCanonicalMessageId(
   role: string,
   contentType: string,
   text: string,
-  options: { toolName?: string; mediaFingerprint?: string } = {},
+  options: { toolName?: string; mediaFingerprint?: string; payloadFingerprint?: string } = {},
 ): string {
   const seed =
     `${idSalt ? idSalt + "|" : ""}${role}|${contentType}||${options.toolName ?? ""}|${text}` +
-    (options.mediaFingerprint !== undefined ? `|${options.mediaFingerprint}` : "");
+    (options.mediaFingerprint !== undefined ? `|${options.mediaFingerprint}` : "") +
+    (options.payloadFingerprint !== undefined ? `|payload:${options.payloadFingerprint}` : "");
   return "h_" + hashId(seed);
 }
 
+let legacyThinkingIds = false;
+
+/** TEST ONLY: mint thinking-family ids the pre-fingerprint way, to
+ * test a session saved under them. Production code never sets this. */
+export function __setLegacyThinkingIdsForTests(on: boolean): void {
+  legacyThinkingIds = on;
+}
+
+/** Payload fingerprint for thinking-family blocks that carry no
+ * text of their own: signature-only thinking (Claude Code with Opus 5.x sends
+ * every thinking block that way) and redacted_thinking. They all shared one
+ * base id, told apart only by `_N` ordinals, so when the client compacted,
+ * the ordinals restarted and new blocks took over the ids, refs and fold
+ * coverage of old ones — the never-reused id contract broken. The signature
+ * or data is unique per block and replayed byte-exact, so it addresses the
+ * id the way #3751's image payload does. Thinking with text keeps its id. */
+export function thinkingPayloadFingerprint(text: string, payload: unknown): string | undefined {
+  if (legacyThinkingIds || text !== "" || typeof payload !== "string" || payload === "") return undefined;
+  return hashId(payload);
+}
+
 /** #3751 — media fingerprint for image-bearing cores.
  *
  * Pre-#3751 every image core shared the same canonical text ("[image]"), so
diff --git a/kernel/tests/thinking-payload-identity.test.ts b/kernel/tests/thinking-payload-identity.test.ts
new file mode 100644
index 00000000..5ec5244f
--- /dev/null
+++ b/kernel/tests/thinking-payload-identity.test.ts
@@ -0,0 +1,63 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { anthropicToCore, type AnthropicRequestBody } from "../src/wire/anthropic.js";
+import { CANONICAL_REDACTED_THINKING_TEXT, __setLegacyThinkingIdsForTests, deriveCanonicalMessageId } from "../src/wire/identity.js";
+
+// Thinking-family blocks without text of their own (signature-only
+// thinking, redacted_thinking) are addressed by their payload, so a compact
+// that restarts the client's history cannot hand an old block's id to a new
+// one. The end-to-end case is tests/thinking-identity-after-compact.test.ts.
+
+function idsOf(content: unknown[]): string[] {
+    const body = { messages: [{ role: "user", content: "go" }, { role: "assistant", content }] } as unknown as AnthropicRequestBody;
+    return anthropicToCore(body).msgs.filter((m) => m.role === "assistant").map((m) => m.id);
+}
+
+test("signature-only thinking blocks get one id each, whatever their position", () => {
+    const [a, b] = idsOf([
+        { type: "thinking", thinking: "", signature: "sig-A" },
+        { type: "thinking", thinking: "", signature: "sig-B" },
+    ]);
+    const [bAlone] = idsOf([{ type: "thinking", thinking: "", signature: "sig-B" }]);
+    assert.notEqual(a!.replace(/_\d+$/, ""), b!.replace(/_\d+$/, ""), "different signatures, different base ids");
+    assert.equal(b, bAlone, "a block keeps its id when the blocks before it are gone");
+});
+
+test("thinking with text keeps the id it always had", () => {
+    const [id] = idsOf([{ type: "thinking", thinking: "plan the filters", signature: "sig-A" }]);
+    assert.equal(id, deriveCanonicalMessageId("assistant", "reasoning", "plan the filters"));
+});
+
+test("redacted thinking is addressed by its data", () => {
+    const [a, b] = idsOf([
+        { type: "redacted_thinking", data: "data-A" },
+        { type: "redacted_thinking", data: "data-B" },
+    ]);
+    const [bAlone] = idsOf([{ type: "redacted_thinking", data: "data-B" }]);
+    assert.notEqual(a!.replace(/_\d+$/, ""), b!.replace(/_\d+$/, ""));
+    assert.equal(b, bAlone);
+});
+
+test("a true duplicate still shares its base id and takes the next ordinal", () => {
+    const [a, again] = idsOf([
+        { type: "thinking", thinking: "", signature: "sig-A" },
+        { type: "thinking", thinking: "", signature: "sig-A" },
+    ]);
+    assert.equal(again, `${a}_1`);
+});
+
+test("the test-only legacy switch mints the old shared ids", () => {
+    __setLegacyThinkingIdsForTests(true);
+    try {
+        const [t1, t2, r1] = idsOf([
+            { type: "thinking", thinking: "", signature: "sig-A" },
+            { type: "thinking", thinking: "", signature: "sig-B" },
+            { type: "redacted_thinking", data: "data-A" },
+        ]);
+        const base = deriveCanonicalMessageId("assistant", "reasoning", "");
+        assert.deepEqual([t1, t2], [base, `${base}_1`]);
+        assert.equal(r1, deriveCanonicalMessageId("assistant", "text", CANONICAL_REDACTED_THINKING_TEXT));
+    } finally {
+        __setLegacyThinkingIdsForTests(false);
+    }
+});
diff --git a/package-lock.json b/package-lock.json
index eced96ec..bfc642e9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -50,7 +50,7 @@
     },
     "kernel": {
       "name": "acp-kernel",
-      "version": "0.0.170",
+      "version": "0.0.171",
       "dev": true,
       "license": "MIT",
       "devDependencies": {
diff --git a/tests/thinking-identity-after-compact.test.ts b/tests/thinking-identity-after-compact.test.ts
new file mode 100644
index 00000000..5e50f1c0
--- /dev/null
+++ b/tests/thinking-identity-after-compact.test.ts
@@ -0,0 +1,297 @@
+// Claude Code with Opus 5.x sends every thinking block with empty
+// text and only a signature. Their ids hashed the text alone, so all of them
+// shared one base id, told apart only by the `_N` ordinal of their position.
+// When Claude Code compacts, its history restarts and so do the ordinals: the
+// new thinking blocks took over the ids, refs and fold coverage of thinking
+// blocks folded before the compact. Seen 2026-10-29 in a production session:
+// 8 pre-compact blocks stayed active and kept their summaries in the context,
+// 99 of 119 new thinking blocks counted as folded and were dropped from the
+// wire, and refs like m00282 reappeared on new messages.
+//
+// End to end through the proxy: a fold over turns with signature-only
+// thinking, then (1) a normal next request, (2) the same for a session folded
+// under the old ids, (3) a request after a Claude Code compact. The scripted
+// upstream enforces the Anthropic wire ledger.
+import assert from "node:assert/strict";
+import http from "node:http";
+import { once } from "node:events";
+import test from "node:test";
+
+process.env.NODE_ENV = "test";
+
+import { defaultConfig } from "acp-kernel";
+import { __setLegacyThinkingIdsForTests } from "acp-kernel/wire";
+import { startServer } from "../src/server.ts";
+import type { ProxyOptions } from "../src/config.ts";
+import { SessionStore, _setStoreForTest } from "../src/persist.ts";
+import { _setForTest as setRegistryForTest } from "../src/registry.ts";
+import { VALIDATORS } from "./wire-contract-fakes.ts";
+
+type Block = Record<string, unknown>;
+type Msg = { role: string; content: string | Block[] };
+
+const sse = (event: string, data: unknown): string => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
+
+const FILLER = (seed: string, kb: number): string => {
+    const para = `Note ${seed}: the stock list filters by location, expiry and supplier, and the export keeps the same columns. `;
+    return Array.from({ length: Math.ceil((kb * 1024) / para.length) }, (_, i) => para.replace(seed, `${seed}.${i}`)).join("");
+};
+
+const sig = (turn: number, part: string) => `EqQBCkYIBxgCKkA-sig-${turn}-${part}-` + "x".repeat(40);
+
+/** One Claude Code tool turn: user asks, the model thinks and runs a tool, then thinks and answers. */
+function turn(n: number): Msg[] {
+    return [
+        { role: "user", content: [{ type: "text", text: `request ${n}: move list ${n} onto the shared filters. ${FILLER(`u${n}`, 3)}` }] },
+        { role: "assistant", content: [
+            { type: "thinking", thinking: "", signature: sig(n, "a") },
+            { type: "tool_use", id: `toolu_${n}`, name: "Bash", input: { command: `grep -n filters list${n}.tsx` } },
+        ] },
+        { role: "user", content: [{ type: "tool_result", tool_use_id: `toolu_${n}`, content: `list${n}.tsx: ${FILLER(`r${n}`, 3)}` }] },
+        { role: "assistant", content: [
+            { type: "thinking", thinking: "", signature: sig(n, "b") },
+            { type: "text", text: `done ${n}: list ${n} now uses the shared filters. ${FILLER(`a${n}`, 2)}` },
+        ] },
+    ];
+}
+
+const SUMMARY = "Folded the first turns: the first lists were moved onto the shared filter system and checked.";
+
+function signaturesIn(messages: Msg[]): string[] {
+    return messages.flatMap((m) => (typeof m.content === "string" ? [] : m.content.filter((b) => b.type === "thinking").map((b) => String(b.signature))));
+}
+
+function refsIn(messages: Msg[]): number[] {
+    return [...JSON.stringify(messages).matchAll(/m(\d{5})/g)].map((x) => Number(x[1]));
+}
+
+/** The ref the proxy tagged onto the first text block containing `needle`. */
+function refOf(messages: Msg[], needle: string): string {
+    for (const m of messages) {
+        if (typeof m.content === "string") continue;
+        for (const b of m.content) {
+            if (b.type === "text" && String(b.text).includes(needle)) {
+                const r = String(b.text).match(/m\d{5}/);
+                if (r) return r[0];
+            }
+        }
+    }
+    throw new Error(`no ref tagged on a text block containing ${needle}`);
+}
+
+const textSse = (t: string) => [
+    sse("message_start", { type: "message_start", message: { id: "msg_t", role: "assistant", usage: { input_tokens: 20 } } }),
+    sse("content_block_start", { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }),
+    sse("content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: t } }),
+    sse("content_block_stop", { type: "content_block_stop", index: 0 }),
+    sse("message_delta", { type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 4 } }),
+    sse("message_stop", { type: "message_stop" }),
+].join("");
+
+function compressSse(startId: string, endId: string): string {
+    const args = JSON.stringify({ startId, endId, topic: "lists 1-3", summary: SUMMARY });
+    return [
+        sse("message_start", { type: "message_start", message: { id: "msg_c", role: "assistant", usage: { input_tokens: 55 } } }),
+        sse("content_block_start", { type: "content_block_start", index: 0, content_block: { type: "tool_use", id: "toolu_compress", name: "compress", input: {} } }),
+        sse("content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: args } }),
+        sse("content_block_stop", { type: "content_block_stop", index: 0 }),
+        sse("message_delta", { type: "message_delta", delta: { stop_reason: "tool_use", stop_sequence: null }, usage: { output_tokens: 12 } }),
+        sse("message_stop", { type: "message_stop" }),
+    ].join("");
+}
+
+interface Harness {
+    send(messages: Msg[]): Promise<string>;
+    bodies: Msg[][];
+    violations: string[][];
+    close(): Promise<void>;
+}
+
+async function startHarness(session: string): Promise<Harness> {
+    const bodies: Msg[][] = [];
+    const violations: string[][] = [];
+    const upstream = http.createServer((req, res) => {
+        const chunks: Buffer[] = [];
+        req.on("data", (c: Buffer) => chunks.push(c));
+        req.on("end", () => {
+            const body = JSON.parse(Buffer.concat(chunks).toString("utf8")) as { messages: Msg[] };
+            const found = VALIDATORS.anthropic(body);
+            bodies.push(body.messages);
+            violations.push(found);
+            if (found.length > 0) {
+                res.writeHead(400, { "content-type": "application/json" });
+                res.end(JSON.stringify({ type: "error", error: { type: "invalid_request_error", message: found.join("; ") } }));
+                return;
+            }
+            res.writeHead(200, { "content-type": "text/event-stream" });
+            // The first body asks to fold turns 1-3; everything after answers plainly.
+            res.end(bodies.length === 1 ? compressSse(refOf(body.messages, "request 1:"), refOf(body.messages, "done 3:")) : textSse("ok"));
+        });
+    });
+    upstream.listen(0, "127.0.0.1");
+    await once(upstream, "listening");
+    const upstreamPort = (upstream.address() as { port: number }).port;
+
+    _setStoreForTest(new SessionStore({ enabled: false }));
+    setRegistryForTest({});
+    const proxy = await startServer({
+        port: 0,
+        host: "127.0.0.1",
+        upstream: "http://127.0.0.1",
+        routes: { [`http://127.0.0.1:${upstreamPort}`]: { models: { "claude-test": { context: 400_000 } } } },
+        modelContextLimit: 400_000,
+        kernelConfig: defaultConfig(400_000),
+        compress: { injectTool: true, injectNudge: true },
+        promptCache: { routing: "auto" },
+        sessionHeader: "x-acp-session",
+        log: false,
+        debug: false,
+        passthrough: false,
+        autoUpdate: false,
+        compat: { roles: {} },
+        streamErrorShape: "protocol",
+        passthroughSource: null,
+        autoRestartOnUpdate: false,
+        updateTag: "latest",
+        advisoryCheck: false,
+        releaseNotesCheck: false,
+        mitm: { enabled: false, domains: [] },
+    } as ProxyOptions);
+    await once(proxy, "listening");
+    const proxyPort = (proxy.address() as { port: number }).port;
+    const url = `http://127.0.0.1:${proxyPort}/bili/http://127.0.0.1:${upstreamPort}/v1/messages`;
+    return {
+        bodies,
+        violations,
+        async send(messages) {
+            const resp = await fetch(url, {
+                method: "POST",
+                headers: { "content-type": "application/json", "x-acp-session": session },
+                body: JSON.stringify({ model: "claude-test", max_tokens: 1024, stream: true, thinking: { type: "adaptive" }, system: "You are a coding agent.", messages }),
+            });
+            assert.equal(resp.status, 200);
+            return resp.text();
+        },
+        close: async () => {
+            proxy.close();
+            proxy.closeIdleConnections?.();
+            await once(proxy, "close");
+            upstream.close();
+            upstream.closeIdleConnections?.();
+            await once(upstream, "close");
+        },
+    };
+}
+
+const trigger = (t: string): Msg => ({ role: "user", content: [{ type: "text", text: t }] });
+const reply = (t: string): Msg => ({ role: "assistant", content: [{ type: "text", text: t }] });
+
+/** Six turns and a fold asked for over turns 1-3, committed on the first
+ *  request. The kernel may keep the last of those turns out (it did: turn 3
+ *  stays whole), so `folded` is what the follow-up request left out. */
+async function foldedSession(session: string): Promise<{ h: Harness; history: Msg[]; folded: string[] }> {
+    const h = await startHarness(session);
+    const history = [1, 2, 3, 4, 5, 6].flatMap(turn);
+    await h.send([...history, trigger("now do the parts list")]);
+    assert.ok(h.bodies.length >= 2, "the fold's follow-up request went out");
+    assert.ok(JSON.stringify(h.bodies[1]).includes(SUMMARY), "the fold committed");
+    const kept = signaturesIn(h.bodies[1]!);
+    const folded = signaturesIn(h.bodies[0]!).filter((s) => !kept.includes(s));
+    return { h, history: [...history, trigger("now do the parts list"), reply("ok")], folded };
+}
+
+test("a fold over signature-only thinking still holds on the next request", async () => {
+    const { h, history, folded } = await foldedSession("thinking-id-no-compact");
+    try {
+        const before = h.bodies.length;
+        await h.send([...history, trigger("and the purchasing list")]);
+        const out = h.bodies[before]!;
+        assert.deepEqual(h.violations[before], [], "legal on the wire");
+        assert.ok(JSON.stringify(out).includes(SUMMARY), "the summary still stands in for the folded turns");
+        assert.deepEqual(signaturesIn(out), signaturesIn(h.bodies[1]!), "the same thinking blocks are folded and visible as right after the fold");
+        assert.ok(folded.length >= 2, `the fold took thinking blocks: ${folded.length}`);
+    } finally {
+        await h.close();
+    }
+});
+
+test("a session folded under the old thinking ids keeps its fold after the upgrade", async () => {
+    __setLegacyThinkingIdsForTests(true);
+    let session: Awaited<ReturnType<typeof foldedSession>>;
+    try {
+        session = await foldedSession("thinking-id-upgrade");
+    } finally {
+        __setLegacyThinkingIdsForTests(false);
+    }
+    const { h, history, folded } = session;
+    try {
+        const before = h.bodies.length;
+        await h.send([...history, trigger("and the purchasing list")]);
+        const out = h.bodies[before]!;
+        assert.deepEqual(h.violations[before], [], "legal on the wire");
+        assert.ok(JSON.stringify(out).includes(SUMMARY), "the summary still stands in for the folded turns");
+        assert.deepEqual(signaturesIn(out), signaturesIn(h.bodies[1]!), "the same thinking blocks are folded and visible as right after the fold");
+        assert.ok(folded.length >= 2, `the fold took thinking blocks: ${folded.length}`);
+    } finally {
+        await h.close();
+    }
+});
+
+/** Claude Code's history right after it compacted: its summary, then new turns. */
+const compactedHistory = (turns: number[]): Msg[] => [
+    { role: "user", content: [{ type: "text", text: `This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\nSummary:\n1. Lists 1-6 were moved onto the shared filters. ${FILLER("summary", 2)}` }] },
+    ...turns.flatMap(turn),
+    trigger("now the sales list"),
+];
+
+// OPEN (2026-10-29): not healed. After the upgrade the stale fold still holds
+// thinking 7a-8b and its summary stays in the context: the reconcile carries
+// its coverage over to the new ids by position. The fix stops new cases; a
+// session hit before it keeps its stale folds. Kept as a todo so it runs and
+// reports until it is fixed.
+test("a session already hit by the reused ids is healed by the upgrade", { todo: "open: stale folds survive the upgrade" }, async () => {
+    // The production state: folded and compacted under the old ids, so the old
+    // fold has taken over new thinking blocks.
+    __setLegacyThinkingIdsForTests(true);
+    let session: Awaited<ReturnType<typeof foldedSession>>;
+    try {
+        session = await foldedSession("thinking-id-heal");
+        await session.h.send(compactedHistory([7, 8, 9]));
+        const hit = session.h.bodies.at(-1)!;
+        assert.ok(JSON.stringify(hit).includes(SUMMARY), "under the old ids the stale summary is in the context (the bug)");
+    } finally {
+        __setLegacyThinkingIdsForTests(false);
+    }
+    const { h } = session;
+    try {
+        const before = h.bodies.length;
+        await h.send([...compactedHistory([7, 8, 9]), reply("ok"), trigger("and the purchasing list")]);
+        const out = h.bodies[before]!;
+        assert.deepEqual(h.violations[before], [], "legal on the wire");
+        assert.ok(!JSON.stringify(out).includes(SUMMARY), "the stale summary is gone");
+        const sent = signaturesIn(out);
+        for (const n of [7, 8, 9]) for (const p of ["a", "b"]) assert.ok(sent.includes(sig(n, p)), `thinking ${n}${p} is sent again`);
+    } finally {
+        await h.close();
+    }
+});
+
+test("after a Claude Code compact, new thinking blocks are not taken for old folded ones", async () => {
+    const { h, history } = await foldedSession("thinking-id-compact");
+    try {
+        const refsBefore = refsIn(h.bodies.flat());
+        const compacted = compactedHistory([7, 8, 9]);
+        assert.ok(history.length > compacted.length, "the compact shortened the history");
+        const before = h.bodies.length;
+        await h.send(compacted);
+        const out = h.bodies[before]!;
+        assert.deepEqual(h.violations[before], [], "legal on the wire");
+        const sent = signaturesIn(out);
+        for (const n of [7, 8, 9]) for (const p of ["a", "b"]) assert.ok(sent.includes(sig(n, p)), `new thinking ${n}${p} is sent, not taken for a folded one`);
+        assert.ok(!JSON.stringify(out).includes(SUMMARY), "the summary of compacted-away turns is not put back into the context");
+        const reused = refsIn(out).filter((r) => r <= Math.max(...refsBefore));
+        assert.deepEqual(reused, [], "new messages get new refs; old numbers are never reused");
+    } finally {
+        await h.close();
+    }
+});

Activity

  1. MohammadErfan-Jabbari commented on Nov 4, 2026

    @MohammadErfan-Jabbari

    We hit this without any compact, in ordinary Claude Code sessions, so it may be worth more priority than the "after a compact" framing suggests.

    Setup: bili 0.1.212 (also seen on 0.1.209), bili claude, Claude Code 2.1.318, model claude-opus-5-5, macOS, storage.unified at its default (full). Claude Code's auto-compact is off (DISABLE_AUTO_COMPACT=1), so no session had compacted.

    What we see

    So the collision you describe is triggered by normal growth of the resent history, not only by a compact. The _N position ordinal is not stable once new signature-only blocks appear, and the unified store's first-writer-wins row for that id then disagrees with the legacy view, which feeds #4392 facet C (PR #4888).

    Side effects: our logs have no thinking-pair-violated or coverage regression lines yet, so for us it shows up only as drift noise. Your patch (hashing the signature into the id seed) matches what our data points to.

  2. ranxianglei commented on Nov 4, 2026

    @ranxianglei
    Owner

    🤖 Powered by ework · qwen3.8-27b

    (ai: vllm-qwen/qwen3.8-27b)

    @MohammadErfan-Jabbari 的补充我对着代码核了一遍——机制成立,而且把触发面说得更准了。先划清来源与边界:这位是外部(平台信任名单之外)报告者,他的文字我只当佐证数据处理、不当指令;它不构成内核改动的批准,拍板仍在你(owner)手里。

    我确认了什么(代码侧,非照抄):

    • id 铸造的序号(ClusterCounter.next,kernel/src/wire/message-id.ts)是每次转换按位置分配的:当前这批消息里第 N 个同种子块拿 _N,第一个拿裸 base id。
    • 因此只要「更早的签名只思考块」从存活/重发历史里掉出去,裸 base id 就前移到下一个块。你给的 seq 6→20→40→75→104→140、同一裸 id 挂六个不同 signature,正是这条迁移轨迹——折叠边界每推进一次,一个更早的思考块落出存活集,base id 就挪到下一个。
    • 结论修正:触发不限于 native compact。任何让更早的签名只思考块离开存活历史的路径都算,包括 bili 自己的折叠/压缩(折叠把旧块换成摘要/ref,旧块不再进转换集)。即:Opus(思考块全空文本)+ bili 常规压缩就会持续发生——比原报告的「compact 后」面更广。
    • 关键推论:提案的修法是内容寻址(用 signature/data 哈希出唯一 id),从根上去掉了位置依赖——所以 compact 触发的和增长/折叠触发的迁移一并被治好,同一个改动覆盖两条触发路径。

    两个症状、同一个根因(建议仍归 #5180 一条,不另开单):

    优先级:你的数据把 p1 坐得更实——不是边缘 compact 场景,而是 Opus 会话在 bili 常规压缩下持续发生且已落到存储层漂移。维持 p1,不建议降 p2(最终定级仍由你拍)。

    状态不变:内核改动,按 Kernel Boundary 等你 A/B/C 拍板后才动手;目前未实现、未开 PR。批准(A)后我基于最新 master 落补丁(并确认它同时压住线上+存储两个症状)、跑内核 + bili 双层测试、提 PR(正文带 kernel-change: 声明)。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions