diff --git a/src/panel/panel.ts b/src/panel/panel.ts index b69eddf..f285968 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). */ @@ -49,9 +50,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 (estimated, kernel countTokens scale): what actually * reaches the LLM after compression (kernel's classification over the * pruned projection + measured system prompt). This is the number @@ -88,7 +90,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; @@ -149,7 +151,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 || ""); @@ -158,7 +160,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/src/sync.ts b/src/sync.ts index ab14deb..08a62bc 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -64,6 +64,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 9bc8377..f7a4ea5 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/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"); }); diff --git a/tests/sync-config.test.ts b/tests/sync-config.test.ts index 249b4ce..75e7bcc 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"] }));