Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions src/panel/panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 || "");
Expand All @@ -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)");
Expand Down
8 changes: 8 additions & 0 deletions src/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions tests/panel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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\):/);
Expand All @@ -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");
});
Expand Down
19 changes: 19 additions & 0 deletions tests/sync-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }));
Expand Down
Loading