From 0240d905079a9eba2d402c60644d4da9ab454372 Mon Sep 17 00:00:00 2001 From: ework-agent Date: Mon, 31 Aug 2026 23:57:28 +0800 Subject: [PATCH 1/2] fix(sync): keep host-expanded blocks deactivated across syncBlocks A block the host explicitly decompressed (expanded) was unconditionally re-activated by syncBlocks on the next turn (client re-sends full history -> summary/raw ids present -> active=true), silently re-folding the already-restored messages. Record the expand intent on the block (expanded?: boolean, host-set) and skip re-activation for it in syncBlocks. Blocks deactivated by other means (orphan GC, tier distillation, consumed-by-parent) keep the legacy resurrection behavior. --- src/sync.ts | 8 ++++++++ src/types.ts | 6 ++++++ tests/sync-config.test.ts | 19 +++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/src/sync.ts b/src/sync.ts index 4def326..962ec3f 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -65,6 +65,14 @@ export function syncBlocks( block.active = false; continue; } + // Host-set `expanded` = the user explicitly decompressed this block, so its + // deactivated state is intentional. Re-activating it here would re-fold the + // already-restored messages next turn (double cost + lost originals). Keep + // it inactive; a fresh compress of the same range creates a NEW block. + if (block.expanded) { + block.active = false; + continue; + } block.active = true; // A block whose raw messages were replaced by its rendered summary // (pruned view) is still present — the summary IS the block's visible diff --git a/src/types.ts b/src/types.ts index af117f8..f877312 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,6 +31,12 @@ export interface CompressionBlock { survivedCount: number; generation: BlockGeneration; active: boolean; + /** Host-set: the user explicitly decompressed (expanded) this block, so its + * deactivated state is intentional and must survive syncBlocks re-activation. + * Absent/false for blocks deactivated by other means (orphan GC, tier + * distillation, consumed-by-parent) — those keep the legacy resurrection + * behavior. */ + expanded?: boolean; durationMs?: number; compressCallId?: string; startRef?: string; diff --git a/tests/sync-config.test.ts b/tests/sync-config.test.ts index 76fd5d2..633fdc5 100644 --- a/tests/sync-config.test.ts +++ b/tests/sync-config.test.ts @@ -50,6 +50,25 @@ test("syncBlocks leaves blocks intact when at least one message remains", () => assert.equal(result.state.blocks[0]!.active, true); }); +test("syncBlocks keeps expanded (user-decompressed) blocks deactivated when present", () => { + const state = createInitialState(); + state.blocks.push( + makeBlock({ blockId: "b1", effectiveMessageIds: ["a", "b"], active: false, expanded: true }), + ); + const result = syncBlocks([msg("a"), msg("b")], state); + assert.equal(result.state.blocks[0]!.active, false, "expanded block must stay deactivated"); + assert.deepEqual(result.deactivated, [], "expanded block is not reported as orphaned"); +}); + +test("syncBlocks re-activates a deactivated (non-expanded) block when its messages return", () => { + const state = createInitialState(); + state.blocks.push( + makeBlock({ blockId: "b1", effectiveMessageIds: ["a", "b"], active: false }), + ); + const result = syncBlocks([msg("a"), msg("b")], state); + assert.equal(result.state.blocks[0]!.active, true, "legacy resurrection preserved for non-expanded blocks"); +}); + test("syncBlocks does not mutate input state", () => { const state = createInitialState(); state.blocks.push(makeBlock({ blockId: "b1", effectiveMessageIds: ["x"] })); From 81f61edfca920ea785cec7938b23f9c93c96ff8c Mon Sep 17 00:00:00 2001 From: ework-agent Date: Tue, 1 Sep 2026 13:30:56 +0800 Subject: [PATCH 2/2] fix(panel): correct session-accounting label and mark tokensCompressed cumulative The 'never shrinks' claim on the session-accounting line was false: the host-reported value really does drop after a compress (summaries replace the pruned originals in the window). Relabel it as 'shrinks slower than the sent view' (it still includes the compressed originals, so it lags the sent view). Mark the Blocks 'tokens compressed' figure as cumulative so it no longer reads as a contradiction next to the current Session-only number. Fixes ranxianglei/billion-context-pi#267 (panel copy items). --- src/panel/panel.ts | 18 ++++++++++-------- tests/panel.test.ts | 4 ++-- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/panel/panel.ts b/src/panel/panel.ts index b34cc6d..36c62fc 100644 --- a/src/panel/panel.ts +++ b/src/panel/panel.ts @@ -11,8 +11,9 @@ export interface StatusPanelInput { * Omit to hide the version line. */ version?: string; /** Host session accounting — the SAME number the host footer displays. - * It is the append-only session tree including compressed originals; it - * never shrinks when compression prunes the per-request view. */ + * It includes compressed originals (summaries stay in the window), so it + * shrinks slower than the sent view when compression prunes the + * per-request projection. */ tokenCount: number; /** Measured token count of the host system prompt (host-specific to * obtain; the kernel breakdown does not see it). */ @@ -48,9 +49,10 @@ function bar(value: number, total: number, width: number = 20): string { /** Render the /acp status panel. Three token numbers, each labeled with * its own scale, never mixed in arithmetic: - * - Session accounting (host footer scale): the append-only session tree - * INCLUDING compressed originals. It never shrinks — adapter pruning is - * a per-request transform view the host cannot see. + * - Session accounting (host footer scale): the host's reported context + * size INCLUDING compressed originals (summaries stay in the window), so + * it shrinks slower than the sent view when compression prunes the + * per-request projection. * - Sent view (chars/4 est.): what actually reaches the LLM after * compression (kernel's classification over the pruned projection + * measured system prompt). This is the number compression controls. @@ -84,7 +86,7 @@ export function buildStatusPanel(input: StatusPanelInput): string { lines.push("╰─────────────────────────────────────────────╯"); if (input.version) lines.push(input.version); lines.push(""); - lines.push(`Context (session accounting, host footer scale): ${displayPct}% (${fmt(displayTotal)} / ${fmt(limit)}) — never shrinks; includes compressed originals`); + lines.push(`Context (session accounting, host footer scale): ${displayPct}% (${fmt(displayTotal)} / ${fmt(limit)}) — includes compressed originals; shrinks slower than the sent view`); if (nudge && bd) { const growth = bd.growth; @@ -145,7 +147,7 @@ export function buildStatusPanel(input: StatusPanelInput): string { if (activeBlocksList.length > 0) { lines.push(""); - lines.push(`Blocks: ${activeBlocksList.length} active / ${totalBlocksList.length} total (${fmt(state.stats.tokensCompressed)} tokens compressed)`); + lines.push(`Blocks: ${activeBlocksList.length} active / ${totalBlocksList.length} total (${fmt(state.stats.tokensCompressed)} tokens compressed, cumulative)`); for (const b of activeBlocksList) { const topic = b.topic ? `: ${b.topic}` : `: ${topicFallback(b.summary || "")}`; const summaryTok = defaultCountTokens(b.summary || ""); @@ -154,7 +156,7 @@ export function buildStatusPanel(input: StatusPanelInput): string { } } else if (totalBlocksList.length > 0) { lines.push(""); - lines.push(`Blocks: 0 active / ${totalBlocksList.length} total (${fmt(state.stats.tokensCompressed)} tokens compressed)`); + lines.push(`Blocks: 0 active / ${totalBlocksList.length} total (${fmt(state.stats.tokensCompressed)} tokens compressed, cumulative)`); } else { lines.push(""); lines.push("Blocks: none (nothing compressed yet)"); diff --git a/tests/panel.test.ts b/tests/panel.test.ts index 063c41a..1fcf962 100644 --- a/tests/panel.test.ts +++ b/tests/panel.test.ts @@ -33,7 +33,7 @@ test("panel separates session accounting from sent view", () => { modelContextLimit: 1_000_000, }); - assert.match(text, /Context \(session accounting, host footer scale\): 43% \(430k \/ 1\.0M\) — never shrinks/); + assert.match(text, /Context \(session accounting, host footer scale\): 43% \(430k \/ 1\.0M\) — includes compressed originals; shrinks slower than the sent view/); assert.match(text, /Sent to LLM \(after compression, est\.\): 24k \(2% of limit\)/); assert.doesNotMatch(text, /Session-only/, "omitted without unprunedTokens — no cross-scale subtraction"); assert.match(text, /Token Breakdown \(sent view\):/); @@ -55,7 +55,7 @@ test("panel renders blocks with topic fallback and version line", () => { nextRunId: 1, }; const text = buildStatusPanel({ tokenCount: 1_000, systemPromptTokens: 0, state: state as never, nudge: undefined, modelContextLimit: 200_000 }); - assert.match(text, /Blocks: 1 active \/ 1 total \(25k tokens compressed\)/); + assert.match(text, /Blocks: 1 active \/ 1 total \(25k tokens compressed, cumulative\)/); assert.match(text, /\[b1\] T1 25k→\d+.*Plugin discovery and registrat…/); assert.doesNotMatch(text, /acp-kernel@/, "no version line when omitted"); });