From e41d0acc67fc3d9a29ee22fada01c869f7e3bbc4 Mon Sep 17 00:00:00 2001 From: ci-check Date: Tue, 27 Oct 2026 10:18:31 +0800 Subject: [PATCH 1/5] kernel: keep signed thinking with its tool_use across folds (#4885) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fold range starting at a tool_use could leave the same wire message's signed thinking block behind when prose (or redacted_thinking) sat between them: the backward reasoning-pair scan only walked contiguous reasoning runs, so [thinking(sig)][prose][tool_use] split into orphaned thinking + folded call. The orphan merged into the next surviving assistant message on rebuild and every later Anthropic request failed with 400 'thinking or redacted_thinking blocks in the latest assistant message cannot be modified' — unrecoverable until the host transcript was replayed. Two complementary changes, both gated on signed-thinking presence so unsigned wires stay byte-identical (#564 directional allowance intact): - reasoning-pairs.ts: the backward boundary pull now also crosses assistant text / redacted_thinking separators when the stretch holds a signed thinking family block (thinkingSignature or rawAnthropicBlock type redacted_thinking), pulling the whole wire-message unit into the fold. Double runs ([think][text][think][tool_use]) covered by scanning back to the stretch start. - turn-integrity.ts: new INV3 gate in computeIntegrityWithdrawals — a partially folded run of consecutive assistant messages holding signed thinking is withdrawn wholesale (the existing directional gate only covered turns whose kept side carried a tool-call). Closes the residual paths where boundary adjustment does not run (bN block-boundary ranges, unit-projection.ts carve pipeline). Exported isSignedThinkingFamily() is the single predicate both sides use. kernel 0.0.168 -> 0.0.169 (version discipline, Kernel Boundary rule 2); root package-lock.json synced (lock-sync recipe). Regression suite: kernel/tests/issue4885-signed-thinking-split.test.ts (9 tests incl. unsigned control pinning the unchanged #564 behavior). Authorized via #4886 (owner approved). --- kernel/package-lock.json | 4 +- kernel/package.json | 2 +- kernel/src/reasoning-pairs.ts | 52 +++- kernel/src/turn-integrity.ts | 99 ++++++- .../issue4885-signed-thinking-split.test.ts | 259 ++++++++++++++++++ package-lock.json | 2 +- 6 files changed, 407 insertions(+), 11 deletions(-) create mode 100644 kernel/tests/issue4885-signed-thinking-split.test.ts diff --git a/kernel/package-lock.json b/kernel/package-lock.json index e884794d82..a64c58f7bb 100644 --- a/kernel/package-lock.json +++ b/kernel/package-lock.json @@ -1,12 +1,12 @@ { "name": "acp-kernel", - "version": "0.0.168", + "version": "0.0.169", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "acp-kernel", - "version": "0.0.168", + "version": "0.0.169", "license": "MIT", "devDependencies": { "@types/node": "^22.0.0", diff --git a/kernel/package.json b/kernel/package.json index 8ba064ebc0..4c8eff8fb3 100644 --- a/kernel/package.json +++ b/kernel/package.json @@ -1,6 +1,6 @@ { "name": "acp-kernel", - "version": "0.0.168", + "version": "0.0.169", "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/reasoning-pairs.ts b/kernel/src/reasoning-pairs.ts index f332f73535..d138b0f069 100644 --- a/kernel/src/reasoning-pairs.ts +++ b/kernel/src/reasoning-pairs.ts @@ -1,4 +1,5 @@ import type { CoreMessage } from "./types.js"; +import { isSignedThinkingFamily } from "./turn-integrity.js"; /** * Adjust compression range boundaries to keep a `reasoning` message together @@ -25,6 +26,15 @@ import type { CoreMessage } from "./types.js"; * reasoning run immediately preceding it. This matches the round-trip contract * every adapter relies on when reconstructing reasoning_content. * + * Signed extension (#4885): for UNSIGNED reasoning the adjacency rule above + * holds exactly (the #564 directional allowance depends on it). When the + * stretch preceding a burst carries a SIGNED thinking-family block (Anthropic + * thinking signature / redacted_thinking sidecar), the backward pull widens to + * the whole consecutive reasoning+assistant-text stretch instead: rebuild + * merges that stretch into one provider message, so leaving any part of it + * behind orphans the signature (permanent 400). See + * isSignedThinkingFamily (turn-integrity.ts) for the predicate. + * * @returns Adjusted { startIndex, endIndex } — may be wider than input. */ export function adjustBoundariesForReasoningPairs( @@ -81,18 +91,54 @@ export function adjustBoundariesForReasoningPairs( (msg.contentType === "text" || msg.contentType === "tool-call") ) { // Backward: pull the reasoning run immediately preceding this assistant - // message into the range. + // message into the range (legacy adjacent-run behavior — unchanged for + // unsigned wires, where the #564 directional allowance depends on it). let k = i - 1; while (k >= 0 && messages[k]!.contentType === "reasoning") { k--; } const runStart = k + 1; - if ( + const legacyPull = runStart < i && runStart >= 0 && messages[runStart]!.contentType === "reasoning" && - runStart < newStartIndex + runStart < newStartIndex; + // #4885: rebuild merges the whole consecutive-assistant stretch into ONE + // provider message, so a signed thinking block must never be left behind + // by a fold. The adjacent scan above stops at the FIRST non-reasoning + // message, which orphans the signature when prose sits between the + // thinking and its tool_use ([thinking][text][tool_use], redacted_ + // thinking separators, multiple thinking blocks) — permanent 400 on the + // Anthropic wire. When the stretch preceding this burst carries a signed + // thinking-family block, pull the ENTIRE stretch (contiguous reasoning + + // assistant-text, stopping at anything else — tool results and foreign + // tool-calls always break it) into the range instead. + let s = i; + while ( + s > 0 && + messages[s - 1]!.role === "assistant" && + (messages[s - 1]!.contentType === "text" || + messages[s - 1]!.contentType === "tool-call") ) { + s--; + } + let u = s - 1; + let stretchStart = s; + let signedInStretch = false; + while ( + u >= 0 && + (messages[u]!.contentType === "reasoning" || + (messages[u]!.role === "assistant" && + messages[u]!.contentType === "text")) + ) { + if (!signedInStretch && isSignedThinkingFamily(messages[u]!)) + signedInStretch = true; + u--; + stretchStart = u + 1; + } + if (signedInStretch && stretchStart < newStartIndex) { + newStartIndex = stretchStart; + } else if (legacyPull) { newStartIndex = runStart; } } diff --git a/kernel/src/turn-integrity.ts b/kernel/src/turn-integrity.ts index 9615f0f80b..7d114d1636 100644 --- a/kernel/src/turn-integrity.ts +++ b/kernel/src/turn-integrity.ts @@ -7,6 +7,34 @@ function isAssistantAct(msg: CoreMessage): boolean { ); } +/** Wire-sidecar fields carrying signature-verified thinking-family payloads + * (Anthropic thinking.signature, redacted_thinking blocks). Typed + * structurally rather than as BiliMessage because core modules must not + * import from src/wire/. */ +interface SignedThinkingSidecar { + thinkingSignature?: string; + rawAnthropicBlock?: unknown; +} + +/** #4885: does this core carry a signature-verified thinking-family payload? + * Anthropic verifies thinking / redacted_thinking blocks byte-for-byte when + * their message is replayed; separating such a block from the siblings of + * its wire message desynchronizes the signature from its context and bricks + * the session (400 "thinking ... blocks cannot be modified"). OpenAI + * reasoning_content is deliberately NOT counted: strict-echo providers + * accept the #564 reverse split (reasoning kept, call folded). */ +export function isSignedThinkingFamily(msg: CoreMessage): boolean { + const m = msg as CoreMessage & SignedThinkingSidecar; + if (typeof m.thinkingSignature === "string" && m.thinkingSignature.length > 0) + return true; + const ab = m.rawAnthropicBlock; + return ( + !!ab && + typeof ab === "object" && + (ab as { type?: unknown }).type === "redacted_thinking" + ); +} + /** * Atomic turn groups for compression integrity (#684). * @@ -124,16 +152,23 @@ export interface IntegrityWithdrawals { * withdrawn wholesale (all members stay visible). * - INV2 — a tool-call and its tool-result are one exchange: a pair the fold * would split is withdrawn together. + * - INV3 (#4885) — a run of consecutive assistant messages carrying a signed + * thinking-family block rebuilds into ONE provider message; partially + * folding such a run orphans the signature (it merges into the next + * surviving message → 400 on every request). A partially folded signed run + * is withdrawn wholesale. Unsigned runs keep the #564 directional + * allowance; a signed run folding TOGETHER is ordinary compression. * - * The two invariants feed each other. Withdrawing a call for INV2 leaves a turn + * The invariants feed each other. Withdrawing a call for INV2 leaves a turn * whose reasoning still folds while its call now stays visible — exactly the * INV1 split — and withdrawing a turn for INV1 can strand a result whose call * survived outside the turn. One pass in a fixed order misses both, so the gate * repeats until nothing changes; a turn or pair is acted on once and reported * once. * - * The reverse direction stays allowed (#564): reasoning and text may stay - * visible while the call and its result fold, since that leaves a valid stream. + * The reverse direction stays allowed (#564): unsigned reasoning and text may + * stay visible while the call and its result fold, since that leaves a valid + * stream. Signed thinking-family blocks are the exception (INV3). */ export function computeIntegrityWithdrawals( messages: CoreMessage[], @@ -165,10 +200,64 @@ export function computeIntegrityWithdrawals( } const groups = computeTurnGroups(messages); + + // #4885: maximal runs of consecutive assistant-role messages that carry at + // least one signed thinking-family block. Rebuild merges each run into ONE + // provider message, so a partially folded run desynchronizes the signature + // from its context. Tool results (role "tool") break runs by construction. + const signedRuns: string[][] = []; + { + let i = 0; + while (i < messages.length) { + if (messages[i]!.role !== "assistant") { + i++; + continue; + } + let j = i; + while (j + 1 < messages.length && messages[j + 1]!.role === "assistant") + j++; + let signed = false; + for (let k = i; k <= j && !signed; k++) + signed = isSignedThinkingFamily(messages[k]!); + if (signed) { + const members: string[] = []; + for (let k = i; k <= j; k++) { + const id = messages[k]!.id; + if (id) members.push(id); + } + signedRuns.push(members); + } + i = j + 1; + } + } + const handledSignedRuns = new Set(); + let signedRunWithdrawals = 0; + let changed = true; while (changed) { changed = false; + for (let r = 0; r < signedRuns.length; r++) { + if (handledSignedRuns.has(r)) continue; + const members = signedRuns[r]!; + // Partial fold only: a run folding together (all members in `remaining`) + // or staying whole (none in it) is fine — ordinary compression / no-op. + let foldedAny = false; + let keptAny = false; + for (const id of members) { + if (remaining.has(id)) foldedAny = true; + else keptAny = true; + } + if (!foldedAny || !keptAny) continue; + handledSignedRuns.add(r); + for (const id of members) { + remaining.delete(id); + withdrawn.add(id); + } + signedRunWithdrawals++; + changed = true; + } + for (let g = 0; g < groups.length; g++) { if (handledTurns.has(g)) continue; const group = groups[g]!; @@ -206,7 +295,9 @@ export function computeIntegrityWithdrawals( return { withdrawn, - splitTurnCount: handledTurns.size, + // Signed-run withdrawals count as turn splits: they keep whole assistant + // turns intact for the same reason INV1 does. + splitTurnCount: handledTurns.size + signedRunWithdrawals, splitPairCount: handledPairs.size, }; } diff --git a/kernel/tests/issue4885-signed-thinking-split.test.ts b/kernel/tests/issue4885-signed-thinking-split.test.ts new file mode 100644 index 0000000000..3a28f70a19 --- /dev/null +++ b/kernel/tests/issue4885-signed-thinking-split.test.ts @@ -0,0 +1,259 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { createCore } from "../src/compress.js"; +import { createInitialState } from "../src/state.js"; +import { defaultConfig } from "../src/config.js"; +import { + computeIntegrityWithdrawals, + isSignedThinkingFamily, +} from "../src/turn-integrity.js"; +import type { CoreMessage } from "../src/types.js"; + +/** CoreMessage plus the Anthropic wire sidecars the codecs attach + * (structurally typed — mirrors the MediaSidecar discipline in + * protected.ts: core modules/tests do not import from src/wire/). */ +type SignedCore = CoreMessage & { + thinkingSignature?: string; + rawAnthropicBlock?: unknown; +}; + +function user(id: string): CoreMessage { + return { + id, + role: "user", + contentType: "text", + text: `${id} ` + "x".repeat(200), + }; +} +function asstText(id: string): CoreMessage { + return { + id, + role: "assistant", + contentType: "text", + text: `prose-${id} ` + "z".repeat(200), + }; +} +function reasoning(id: string, signature?: string): SignedCore { + return { + id, + role: "assistant", + contentType: "reasoning", + text: `think-${id} ` + "t".repeat(400), + ...(signature ? { thinkingSignature: signature } : {}), + }; +} +function redacted(id: string): SignedCore { + return { + id, + role: "assistant", + contentType: "text", + text: "[redacted_thinking]", + rawAnthropicBlock: { type: "redacted_thinking", data: "REDACTED" }, + }; +} +function toolCall(id: string, callId: string): CoreMessage { + return { + id, + role: "assistant", + contentType: "tool-call", + toolName: "bash", + toolCallId: callId, + text: `call-${callId}`, + }; +} +function toolResult(id: string, callId: string): CoreMessage { + return { + id, + role: "tool", + contentType: "tool-result", + toolName: "bash", + toolCallId: callId, + text: `result-${callId} ` + "r".repeat(200), + }; +} + +const config = () => ({ + ...defaultConfig(150000), + compress: { + ...defaultConfig(150000).compress, + minCompressRange: 1, + minSummaryLength: 1, + preserveRecentMessages: 0, + }, +}); + +function fold(messages: SignedCore[], startId: string, endId: string) { + const core = createCore(); + let state = createInitialState(); + state = core.processTurn({ + messages, + state, + config: config(), + tokenCount: (t) => Math.ceil(t.length / 4), + }).state; + const refs = state.messageRefs.byRaw; + const res = core.applyCompression({ + state, + messages, + config: config(), + protectedMessageIds: new Set(), + ranges: [ + { startRef: refs[startId]!, endRef: refs[endId]!, summary: "s".repeat(400) }, + ], + }); + const block = res.state.blocks.find((b) => b.active); + return { res, blockIds: block ? [...block.effectiveMessageIds] : [] }; +} + +// Reported shape: one assistant message [thinking(sig)][prose][tool_use], +// model targets the tool loop starting AT the tool_use. +const signedShape = (): SignedCore[] => [ + user("u1"), + user("ulast"), + reasoning("ra", "sig-A"), + asstText("tm"), + toolCall("ca", "call_A"), + toolResult("tra", "call_A"), + reasoning("rb", "sig-B"), + toolCall("cb", "call_B"), + toolResult("trb", "call_B"), +]; + +describe("#4885 signed thinking must fold with its tool_use", () => { + it("pulls signed thinking + prose into a range starting at the tool_use", () => { + const { res, blockIds } = fold(signedShape(), "ca", "trb"); + assert.deepEqual(res.result.errors, []); + // Whole wire-message unit folds: thinking A, its prose, and the burst. + for (const id of ["ra", "tm", "ca", "tra", "rb", "cb", "trb"]) { + assert.ok(blockIds.includes(id), `${id} must fold (got ${blockIds})`); + } + }); + + it("same shape with a redacted_thinking separator", () => { + const msgs: SignedCore[] = [ + user("u1"), + user("ulast"), + reasoning("ra", "sig-A"), + redacted("red"), + toolCall("ca", "call_A"), + toolResult("tra", "call_A"), + reasoning("rb", "sig-B"), + toolCall("cb", "call_B"), + toolResult("trb", "call_B"), + ]; + const { res, blockIds } = fold(msgs, "ca", "trb"); + assert.deepEqual(res.result.errors, []); + for (const id of ["ra", "red", "ca", "tra"]) { + assert.ok(blockIds.includes(id), `${id} must fold (got ${blockIds})`); + } + }); + + it("pulls multiple signed thinking blocks separated by prose (double run)", () => { + const msgs: SignedCore[] = [ + user("u1"), + user("ulast"), + reasoning("ra1", "sig-A1"), + asstText("tm"), + reasoning("ra2", "sig-A2"), + toolCall("ca", "call_A"), + toolResult("tra", "call_A"), + ]; + const { res, blockIds } = fold(msgs, "ca", "tra"); + assert.deepEqual(res.result.errors, []); + for (const id of ["ra1", "tm", "ra2", "ca", "tra"]) { + assert.ok(blockIds.includes(id), `${id} must fold (got ${blockIds})`); + } + }); + + it("unsigned control: prose-separated reasoning stays OUT of the fold (#564)", () => { + const msgs: SignedCore[] = [ + user("u1"), + user("ulast"), + reasoning("ra"), + asstText("tm"), + toolCall("ca", "call_A"), + toolResult("tra", "call_A"), + reasoning("rb"), + toolCall("cb", "call_B"), + toolResult("trb", "call_B"), + ]; + const { res, blockIds } = fold(msgs, "ca", "trb"); + assert.deepEqual(res.result.errors, []); + // Adjacent (no prose) reasoning still pulls in — legacy behavior intact. + assert.ok(blockIds.includes("rb")); + // Prose-separated unsigned reasoning keeps the #564 directional allowance. + assert.ok(!blockIds.includes("ra"), "unsigned ra must stay visible"); + assert.ok(!blockIds.includes("tm"), "prose must stay visible"); + assert.ok(blockIds.includes("ca")); + assert.ok(blockIds.includes("tra")); + }); + + it("signed run folding TOGETHER remains ordinary compression", () => { + const { res, blockIds } = fold(signedShape(), "ra", "trb"); + assert.deepEqual(res.result.errors, []); + for (const id of ["ra", "tm", "ca", "tra", "rb", "cb", "trb"]) { + assert.ok(blockIds.includes(id), `${id} must fold (got ${blockIds})`); + } + }); +}); + +describe("#4885 INV3 gate (computeIntegrityWithdrawals)", () => { + const unit = (): SignedCore[] => [ + reasoning("ra", "sig-A"), + asstText("tm"), + toolCall("ca", "call_A"), + toolResult("tra", "call_A"), + user("u9"), + ]; + + it("withdraws a partially folded signed run wholesale", () => { + const { withdrawn, splitTurnCount } = computeIntegrityWithdrawals( + unit(), + new Set(["ca", "tra"]), + ); + for (const id of ["ra", "tm", "ca", "tra"]) { + assert.ok(withdrawn.has(id), `${id} must be withdrawn`); + } + assert.ok(splitTurnCount >= 1); + }); + + it("leaves a fully folded signed run alone (ordinary compression)", () => { + const { withdrawn } = computeIntegrityWithdrawals( + unit(), + new Set(["ra", "tm", "ca", "tra"]), + ); + assert.equal(withdrawn.size, 0); + }); + + it("keeps the #564 reverse split allowed for UNSIGNED runs", () => { + const unsigned: SignedCore[] = [ + reasoning("ra"), + asstText("tm"), + toolCall("ca", "call_A"), + toolResult("tra", "call_A"), + user("u9"), + ]; + const { withdrawn } = computeIntegrityWithdrawals( + unsigned, + new Set(["ca", "tra"]), + ); + assert.equal(withdrawn.size, 0); + }); + + it("isSignedThinkingFamily: signature and redacted sidecar only", () => { + assert.ok(isSignedThinkingFamily(reasoning("a", "sig"))); + assert.ok(isSignedThinkingFamily(redacted("r"))); + assert.ok(!isSignedThinkingFamily(reasoning("plain"))); + assert.ok( + !isSignedThinkingFamily({ + id: "oe", + role: "assistant", + contentType: "reasoning", + text: "cot", + // OpenAI reasoning_content echo lane: unsigned by definition. + ...( { reasoningContent: "x" } as object ), + } as SignedCore), + ); + assert.ok(!isSignedThinkingFamily(asstText("t"))); + }); +}); diff --git a/package-lock.json b/package-lock.json index 05241c171f..75960797fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,7 +50,7 @@ }, "kernel": { "name": "acp-kernel", - "version": "0.0.168", + "version": "0.0.169", "dev": true, "license": "MIT", "devDependencies": { From ecdba07c32f8f389f29a788eb65aa41d8db6591b Mon Sep 17 00:00:00 2001 From: ci-check Date: Tue, 27 Oct 2026 11:47:08 +0800 Subject: [PATCH 2/5] kernel: sync unit-projection gate comment with INV3 (#4885 review polish) --- kernel/src/unit-projection.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/kernel/src/unit-projection.ts b/kernel/src/unit-projection.ts index 8b228d8220..7477a02f61 100644 --- a/kernel/src/unit-projection.ts +++ b/kernel/src/unit-projection.ts @@ -323,6 +323,10 @@ export function projectUnit(input: UnitProjectionInput): UnitProjection { // leaves a valid message stream and stays allowed (#564 depends on it), so // only turns whose KEPT side carries a tool-call while the FOLDED side // carries the reasoning are withdrawn, entirely (all members stay visible). + // Signed thinking-family blocks are the exception (#4885 INV3): a run of + // consecutive assistant messages carrying one rebuilds into ONE provider + // message, so a partially folded signed run is withdrawn wholesale in + // either direction — leaving part of it behind orphans the signature. // The same carve can also strip a call and its result onto opposite sides of // the fold. Either half alone is invalid — a visible call whose result folded // is an unanswered tool_call_id, a visible result whose call folded answers @@ -330,7 +334,7 @@ export function projectUnit(input: UnitProjectionInput): UnitProjection { // visible. Whole pairs may still fold while the turn's reasoning and text // stay visible (#564). // - // Both invariants are re-checked to a fixed point inside + // All three invariants are re-checked to a fixed point inside // computeIntegrityWithdrawals: withdrawing a pair for the result half can // strand a turn whose reasoning still folds and whose call now stays visible. { From 7b7c35c335e98e6b54af31429073b806ff8669f9 Mon Sep 17 00:00:00 2001 From: ci-check Date: Tue, 27 Oct 2026 11:47:08 +0800 Subject: [PATCH 3/5] kernel: pin INV3 fixpoint re-check after INV2 withdrawal (#4885 review polish) --- .../issue4885-signed-thinking-split.test.ts | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/kernel/tests/issue4885-signed-thinking-split.test.ts b/kernel/tests/issue4885-signed-thinking-split.test.ts index 3a28f70a19..5cf36c0603 100644 --- a/kernel/tests/issue4885-signed-thinking-split.test.ts +++ b/kernel/tests/issue4885-signed-thinking-split.test.ts @@ -9,12 +9,13 @@ import { } from "../src/turn-integrity.js"; import type { CoreMessage } from "../src/types.js"; -/** CoreMessage plus the Anthropic wire sidecars the codecs attach +/** CoreMessage plus the wire sidecars the codecs attach * (structurally typed — mirrors the MediaSidecar discipline in * protected.ts: core modules/tests do not import from src/wire/). */ type SignedCore = CoreMessage & { thinkingSignature?: string; rawAnthropicBlock?: unknown; + reasoningContent?: string; }; function user(id: string): CoreMessage { @@ -225,6 +226,26 @@ describe("#4885 INV3 gate (computeIntegrityWithdrawals)", () => { assert.equal(withdrawn.size, 0); }); + it("re-checks a signed run after INV2 withdraws its call (fixpoint)", () => { + const msgs: SignedCore[] = [ + reasoning("ra", "sig-A"), + toolCall("ca", "call_A"), + toolResult("tra", "call_A"), + user("u9"), + ]; + // Fold covers thinking + call but NOT the result: INV2 must withdraw + // the call first, and the NEXT fixpoint iteration must catch the now + // partially folded signed run and withdraw the thinking too — a + // signature is never folded alone. + const { withdrawn } = computeIntegrityWithdrawals( + msgs, + new Set(["ra", "ca"]), + ); + assert.ok(withdrawn.has("ca"), "call withdrawn (split pair)"); + assert.ok(withdrawn.has("tra"), "result recorded with its pair"); + assert.ok(withdrawn.has("ra"), "thinking withdrawn on re-check"); + }); + it("keeps the #564 reverse split allowed for UNSIGNED runs", () => { const unsigned: SignedCore[] = [ reasoning("ra"), @@ -251,8 +272,8 @@ describe("#4885 INV3 gate (computeIntegrityWithdrawals)", () => { contentType: "reasoning", text: "cot", // OpenAI reasoning_content echo lane: unsigned by definition. - ...( { reasoningContent: "x" } as object ), - } as SignedCore), + reasoningContent: "x", + }), ); assert.ok(!isSignedThinkingFamily(asstText("t"))); }); From 20d0d8c5102c4790d02d6826f3fa43dba8566bc4 Mon Sep 17 00:00:00 2001 From: ci-check Date: Sun, 8 Nov 2026 00:00:37 +0800 Subject: [PATCH 4/5] kernel: add regression test for #5891 recent-zone signed-orphan shape (sibling of #4885) --- ...ssue5891-recent-zone-signed-orphan.test.ts | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 kernel/tests/issue5891-recent-zone-signed-orphan.test.ts diff --git a/kernel/tests/issue5891-recent-zone-signed-orphan.test.ts b/kernel/tests/issue5891-recent-zone-signed-orphan.test.ts new file mode 100644 index 0000000000..0a404a7a9e --- /dev/null +++ b/kernel/tests/issue5891-recent-zone-signed-orphan.test.ts @@ -0,0 +1,113 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { createCore } from "../src/compress.js"; +import { createInitialState } from "../src/state.js"; +import { defaultConfig } from "../src/config.js"; +import type { CoreMessage } from "../src/types.js"; + +// #5891: a signed reasoning run whose companion Read tool-call/result sits +// outside the recent zone (neverPreserveRecentTools) may be folded while the +// signature-verified thinking stays visible — the orphan desynchronizes the +// signature and every later Anthropic request 400s ("thinking ... blocks +// cannot be modified"). Symptom invariant pinned here: a fold may never take +// the companion act without taking (or withdrawing) the signed run. +type SignedCore = CoreMessage & { thinkingSignature?: string }; + +const config = () => defaultConfig(100_000); + +function buildMessages(): SignedCore[] { + return [ + { + id: "u", + role: "user", + contentType: "text", + text: "Read data.log and report record 42", + }, + { + id: "th1", + role: "assistant", + contentType: "reasoning", + text: "thinking-a ", + thinkingSignature: "sig-1", + }, + { + id: "c1", + role: "assistant", + contentType: "tool-call", + toolName: "Read", + toolCallId: "t1", + text: "{}", + }, + { + id: "r1", + role: "tool", + contentType: "tool-result", + toolName: "Read", + toolCallId: "t1", + text: "x".repeat(40_000), + }, + { + id: "th2", + role: "assistant", + contentType: "reasoning", + text: "thinking-b ", + thinkingSignature: "sig-2", + }, + { + id: "c2", + role: "assistant", + contentType: "tool-call", + toolName: "Grep", + toolCallId: "t2", + text: "{}", + }, + { + id: "r2", + role: "tool", + contentType: "tool-result", + toolName: "Grep", + toolCallId: "t2", + text: "42", + }, + ]; +} + +function foldRange(startId: string, endId: string) { + const messages = buildMessages(); + const core = createCore(); + let state = createInitialState(); + state = core.processTurn({ + messages, + state, + config: config(), + tokenCount: (t) => Math.ceil(t.length / 4), + }).state; + const refs = state.messageRefs.byRaw; + const res = core.applyCompression({ + state, + messages, + config: config(), + ranges: [{ startRef: refs[startId]!, endRef: refs[endId]!, summary: "s".repeat(400) }], + }); + return res.state.blocks.filter((b) => b.active).map((b) => new Set(b.effectiveMessageIds)); +} + +describe("issue5891 recent-zone signed-thinking orphan", () => { + it("folding the Read exchange alone must not strand its signed thinking", () => { + for (const ids of foldRange("c1", "r1")) { + assert.ok( + !ids.has("c1") || ids.has("th1"), + `orphaned signed thinking: block ${[...ids].join(",")} folded c1 without th1`, + ); + } + }); + + it("a range spanning the signed run plus the exchange must not carve the run out", () => { + for (const ids of foldRange("th1", "r1")) { + assert.ok( + !ids.has("c1") || ids.has("th1"), + `orphaned signed thinking: block ${[...ids].join(",")} folded c1 without th1`, + ); + } + }); +}); From 2234043b6f2c96dcb81b15c8dee0103b150fb3a1 Mon Sep 17 00:00:00 2001 From: ci-check Date: Sun, 8 Nov 2026 23:44:37 +0800 Subject: [PATCH 5/5] test(#5152): close the receiver-port publication race behind the "port got 0" CI flake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a loaded CI shard (PR #4898 run 42492536511, test ubuntu-24 shard 1) "#5152 a worker that dies mid-flight falls back to the in-thread loop" failed with "receiver port must be a number, got 0": startReceiver gated on mere existence of beats.txt.port, while the fixture published it with appendFileSync (open(O_CREAT) + write are two syscalls). Under load the child descheduled between them and the parent read an empty file (Number("") === 0). Both sides of the same handoff are closed: - fixture: publish atomically (write .port.tmp, renameSync — atomic on POSIX; the destination never pre-exists, so Windows plain-move semantics hold); - test: poll until the content parses as a positive integer port (same 15 s deadline); a receiver that dies before publishing still fails loudly. Test harness only; no production code touched. --- tests/fixtures/issue5152-beat-receiver.mjs | 10 ++++++++-- ...issue5152-owner-beat-survives-host-stall.test.ts | 13 +++++++++---- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/tests/fixtures/issue5152-beat-receiver.mjs b/tests/fixtures/issue5152-beat-receiver.mjs index 132fbe11aa..d4ad72a49a 100644 --- a/tests/fixtures/issue5152-beat-receiver.mjs +++ b/tests/fixtures/issue5152-beat-receiver.mjs @@ -3,7 +3,7 @@ // reaching it while the sender's own thread was pinned. An in-process receiver // would be pinned by the same block and the proof would be vacuous. import http from "node:http"; -import { appendFileSync } from "node:fs"; +import { appendFileSync, renameSync, writeFileSync } from "node:fs"; const out = process.argv[2]; if (!out) throw new Error("usage: issue5152-beat-receiver.mjs "); @@ -17,5 +17,11 @@ const server = http.createServer((req, res) => { }); }); server.listen(0, "127.0.0.1", () => { - appendFileSync(`${out}.port`, String(server.address().port)); + // Publish atomically: the parent polls for existence, so a create-then-fill + // sequence lets it read an EMPTY file (port 0). Write a temp name, then + // rename — rename is atomic on POSIX, so the parent sees either nothing or + // the full port string, never a half-written one. + const portFile = `${out}.port`; + writeFileSync(`${portFile}.tmp`, String(server.address().port)); + renameSync(`${portFile}.tmp`, portFile); }); diff --git a/tests/issue5152-owner-beat-survives-host-stall.test.ts b/tests/issue5152-owner-beat-survives-host-stall.test.ts index 671977001c..a9e1ebdffd 100644 --- a/tests/issue5152-owner-beat-survives-host-stall.test.ts +++ b/tests/issue5152-owner-beat-survives-host-stall.test.ts @@ -53,13 +53,18 @@ async function startReceiver(dir: string): Promise { const portFile = `${log}.port`; const child = spawn(process.execPath, [RECEIVER, log], { stdio: "ignore", detached: true }); child.unref(); + // Gate on a VALID port, not mere file existence: a non-atomic publication + // can leave an empty file behind (observed CI flake: "port ... got 0" on a + // loaded shard), which must be re-polled, not asserted on. const deadline = Date.now() + 15_000; - while (!existsSync(portFile) && Date.now() < deadline) { + let raw = ""; + let port = 0; + while (!(Number.isInteger(port) && port > 0) && Date.now() < deadline) { + raw = existsSync(portFile) ? readFileSync(portFile, "utf8").trim() : ""; + port = Number(raw); Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20); } - assert.ok(existsSync(portFile), "receiver must publish its port"); - const port = Number(readFileSync(portFile, "utf8").trim()); - assert.ok(Number.isInteger(port) && port > 0, `receiver port must be a number, got ${port}`); + assert.ok(Number.isInteger(port) && port > 0, `receiver must publish its port (file held ${JSON.stringify(raw)})`); return { port, beats: () => (existsSync(log) ? readFileSync(log, "utf8").trim().split("\n").filter(Boolean).map(Number) : []),