diff --git a/.ework-daemon/issue-body.txt b/.ework-daemon/issue-body.txt new file mode 100644 index 0000000000..c26b132ae8 --- /dev/null +++ b/.ework-daemon/issue-body.txt @@ -0,0 +1,81 @@ +**Setup:** billion-context 0.1.212 (`daa082ead`), Claude Code 2.1.311, `claude-haiku-5-5` with adaptive thinking (signature-only thinking blocks), Anthropic wire through LiteLLM to AWS Bedrock. Config: `{"compress":{"modelContextLimit":55573,"nudgeGrowthTokens":2000}}`. Related: #4885, #4886, PR #4898, #5180. + +**Steps:** +1. Ask the agent to read a large file with `Read` (1,500 lines, about 24K tokens), then answer a question. +2. The nudge makes the model call `compress` on the Read exchange in the same tool loop. +3. bili logs `Compressed m00004–m00005 ... Excluded 1 protected message(s) m00003 from compression range (recent/last-user zone)`. `m00003` is the signed thinking block that comes before the Read `tool_use`. +4. The next outbound assistant message is `[thinking A (orphan), thinking B, text...]`. Upstream returns `400 messages.N.content.1: thinking or redacted_thinking blocks in the latest assistant message cannot be modified`. + +**Rate:** 11 of 123 upstream requests (8.9%) over 11 three-turn sessions. + +**Cause:** `computeProtectedRefs` (`kernel/src/recommend.ts`) skips `neverPreserveRecentTools` matches (`read` by default), so the Read call and result are not protected. Rules 1 and 2 still protect the reasoning core before the call. The #2886 closure covers tool pairs only. Then the advisory carve (`kernel/src/unit-projection.ts:320-323`) removes the protected reasoning from the fold that `adjustBoundariesForReasoningPairs` had widened, and `coreToAnthropic` merges the orphan into the next assistant message. + +**Minimal kernel test** (fails on 0.1.212): messages `[user, reasoning(signed) th1, tool-call Read c1, tool-result Read r1 (40K chars), reasoning(signed) th2, tool-call Grep c2, tool-result r2]` with `defaultConfig(100000)`. Expected: `computeProtectedRefs` does not protect `th1`, because its companion `c1` is not protected. Actual: `th1` is protected and `c1` is not. + +**Proposed fix:** after the #2886 tool-pair closure, remove a reasoning run from the protected set when its assistant companion is not protected (15 lines, patch attached). With it: 0 of 62 upstream 400s, the same fold savings, and all 1,563 kernel tests pass. PR #4898 also fixes this case (0/35 in our back-port). + +A patch for 0.1.212 (about 15 lines in the kernel, plus a regression test) is attached below. + +```diff +diff --git a/kernel/src/recommend.ts b/kernel/src/recommend.ts +index d2cd66982..d2ffed920 100644 +--- a/kernel/src/recommend.ts ++++ b/kernel/src/recommend.ts +@@ -192,6 +192,21 @@ export function computeProtectedRefs( + } + } + ++ for (let i = 0; i < messages.length; i++) { ++ if (messages[i]!.contentType !== "reasoning") continue; ++ let j = i; ++ while (j + 1 < messages.length && messages[j + 1]!.contentType === "reasoning") j++; ++ const companion = messages[j + 1]; ++ const companionRef = companion?.id ? state.messageRefs.byRaw[companion.id] : undefined; ++ if (companion?.role === "assistant" && companionRef && companionRef !== "BLOCKED" && !result.has(companionRef)) { ++ for (let k = i; k <= j; k++) { ++ const ref = state.messageRefs.byRaw[messages[k]!.id]; ++ if (ref) result.delete(ref); ++ } ++ } ++ i = j; ++ } ++ + return result; + } + +diff --git a/kernel/tests/bili-fix-reasoning-protect.test.ts b/kernel/tests/bili-fix-reasoning-protect.test.ts +new file mode 100644 +index 000000000..190bc2041 +--- /dev/null ++++ b/kernel/tests/bili-fix-reasoning-protect.test.ts +@@ -0,0 +1,28 @@ ++import { test } from "node:test"; ++import assert from "node:assert/strict"; ++import { createInitialState } from "../src/state.js"; ++import { assignRefs } from "../src/refs.js"; ++import { computeProtectedRefs } from "../src/recommend.js"; ++import { defaultConfig } from "../src/config.js"; ++import type { CoreMessage } from "../src/types.js"; ++ ++const messages: CoreMessage[] = [ ++ { id: "u", role: "user", contentType: "text", text: "Read data.log and report record 42" }, ++ { id: "th1", role: "assistant", contentType: "reasoning", text: "", 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(40000) }, ++ { id: "th2", role: "assistant", contentType: "reasoning", text: "", 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" }, ++]; ++ ++test("signed reasoning is not protected when its tool-call companion is outside the recent zone", () => { ++ const state = createInitialState(); ++ state.messageRefs = assignRefs(messages, { existing: state.messageRefs, nextIndex: 1 }).map; ++ const ref = (id: string) => state.messageRefs.byRaw[id]!; ++ const protectedRefs = computeProtectedRefs(messages, state, defaultConfig(100000)); ++ assert.equal(protectedRefs.has(ref("c1")), false); ++ assert.equal(protectedRefs.has(ref("th1")), false); ++ assert.equal(protectedRefs.has(ref("th2")), true); ++ assert.equal(protectedRefs.has(ref("c2")), true); ++}); + +``` diff --git a/kernel/package-lock.json b/kernel/package-lock.json index da83f9717b..42edfa9cb7 100644 --- a/kernel/package-lock.json +++ b/kernel/package-lock.json @@ -1,12 +1,12 @@ { "name": "acp-kernel", - "version": "0.0.195", + "version": "0.0.196", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "acp-kernel", - "version": "0.0.195", + "version": "0.0.196", "license": "MIT", "devDependencies": { "@types/node": "^22.0.0", diff --git a/kernel/package.json b/kernel/package.json index c130a693b8..f03fc7adee 100644 --- a/kernel/package.json +++ b/kernel/package.json @@ -1,6 +1,6 @@ { "name": "acp-kernel", - "version": "0.0.195", + "version": "0.0.196", "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 1ee828b628..00c70579bd 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). * @@ -174,16 +202,23 @@ export function buildIntegrityPre(messages: CoreMessage[]): IntegrityPre { * 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). * * Coverage is not "kept". An id in `invisibleIds` is already hidden by an * active block: it neither counts as folding nor as kept-visible. A covered @@ -212,10 +247,64 @@ export function computeIntegrityWithdrawals( const callIdByMessageId = built.callIdByMessageId; const resultIdByCallId = built.resultIdByCallId; const groups = built.groups; + + // #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]!; @@ -255,7 +344,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/src/unit-projection.ts b/kernel/src/unit-projection.ts index 428d3eb672..928e991bd3 100644 --- a/kernel/src/unit-projection.ts +++ b/kernel/src/unit-projection.ts @@ -404,6 +404,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 @@ -411,7 +415,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. { 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..5cf36c0603 --- /dev/null +++ b/kernel/tests/issue4885-signed-thinking-split.test.ts @@ -0,0 +1,280 @@ +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 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 { + 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("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"), + 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", + }), + ); + assert.ok(!isSignedThinkingFamily(asstText("t"))); + }); +}); 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`, + ); + } + }); +}); diff --git a/package-lock.json b/package-lock.json index b333eb55fd..e2ff6fb483 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,7 +50,7 @@ }, "kernel": { "name": "acp-kernel", - "version": "0.0.195", + "version": "0.0.196", "dev": true, "license": "MIT", "devDependencies": { 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) : []),