From f2f91ad7fc8017262d8b49a06223e3acd0b82363 Mon Sep 17 00:00:00 2001 From: ework-agent Date: Tue, 8 Sep 2026 11:09:20 +0800 Subject: [PATCH] fix: announce truncated block lists in status overview; per-tier block counts (#221) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tier-distillation nudge lists every active lower-tier block uncapped, while buildStatusReport's overview silently capped the block list at 30 entries sorted by original size. Once a session accumulates >30 active blocks — exactly the condition under which T2/T3 distillation nudges fire — active target-tier blocks vanish from acp_status and the model reads the two same-source views as contradictory (bcp#330 session 01a07b3c: nudge named 11 T1 targets, status visibly showed only b31/b32), skipping the distillation and re-triggering it on the next turn. - renderOverview: explicit '... and N more blocks not shown' line with a pointer to scope:"compressed", parity with the other two views - tierBreakdown: per-tier active block counts next to token totals so the nudge's target count is checkable even when individual lines are hidden --- src/report.ts | 12 ++- tests/report-truncation.test.ts | 128 ++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 tests/report-truncation.test.ts diff --git a/src/report.ts b/src/report.ts index 17761a8..15c96c7 100644 --- a/src/report.ts +++ b/src/report.ts @@ -45,14 +45,19 @@ function tierBreakdown( countTokens: (t: string) => number, ): string | null { const tierTokens: Record = {}; + const tierCounts: Record = {}; for (const block of blocks) { tierTokens[block.tier] = (tierTokens[block.tier] ?? 0) + summaryTokensOf(block, countTokens); + tierCounts[block.tier] = (tierCounts[block.tier] ?? 0) + 1; } const tiers = Object.keys(tierTokens).map(Number); if (tiers.length <= 1) return null; + // Counts, not just tokens: the tier-distillation nudge lists every active + // lower-tier block uncapped while the block list below may be truncated — + // the count is what lets the model reconcile the two views (#221). const parts: string[] = []; for (const tier of [1, 2, 3]) { - if (tierTokens[tier]) parts.push(`T${tier}: ${formatTokens(tierTokens[tier])}`); + if (tierTokens[tier]) parts.push(`T${tier}: ${formatTokens(tierTokens[tier])} (${tierCounts[tier]} blocks)`); } return parts.join(" | "); } @@ -205,6 +210,11 @@ function renderOverview( ` ${block.blockId} (${tierLabel(block)}) ${formatTokens(eff)}→${formatTokens(summaryTokensOf(block, countTokens))} ${block.effectiveMessageIds.length} msgs "${topic}"`, ); } + if (blocks.length > limit) { + lines.push( + ` ... and ${blocks.length - limit} more blocks not shown (scope:"compressed", limit:${blocks.length} for full list)`, + ); + } } lines.push(""); diff --git a/tests/report-truncation.test.ts b/tests/report-truncation.test.ts new file mode 100644 index 0000000..73ceb3c --- /dev/null +++ b/tests/report-truncation.test.ts @@ -0,0 +1,128 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { buildStatusReport } from "../src/report.js"; +import { renderNudgeText } from "../src/nudge-text.js"; +import { createInitialState } from "../src/state.js"; +import { defaultCountTokens } from "../src/tokenize.js"; +import type { CompressionBlock, CompressionState, NudgeDecision } from "../src/types.js"; + +function block(overrides: Partial): CompressionBlock { + return { + blockId: "b0", + runId: "r0", + tier: 1, + summary: "summary", + directMessageIds: [], + effectiveMessageIds: [], + directBlockIds: [], + createdAt: 1000, + survivedCount: 0, + generation: "young", + active: true, + ...overrides, + }; +} + +// Mirrors bcp#330 session 01a07b3c: 41 active blocks (35 T1 + 6 T2) — the +// exact condition under which a tier-2 distillation nudge fires AND the +// overview's 30-entry cap starts hiding active target-tier blocks. +function makeSessionState(): CompressionState { + const blocks: CompressionBlock[] = []; + for (let i = 1; i <= 35; i++) { + blocks.push( + block({ + blockId: `b${i}`, + tier: 1, + summary: `tier-1 summary ${i}`, + topic: `topic-${i}`, + compressedTokens: 8000 + i * 100, + effectiveMessageIds: Array.from({ length: 5 }, (_, j) => `m${i}_${j}`), + createdAt: 1000 + i, + }), + ); + } + for (let i = 36; i <= 41; i++) { + blocks.push( + block({ + blockId: `b${i}`, + tier: 2, + summary: `tier-2 summary ${i}`, + topic: `distilled-${i}`, + compressedTokens: 40000 + i * 100, + effectiveMessageIds: Array.from({ length: 40 }, (_, j) => `m${i}_${j}`), + directBlockIds: [`b${i - 3}`], + createdAt: 5000 + i, + }), + ); + } + return { ...createInitialState(), blocks }; +} + +function tier2Decision(state: CompressionState): NudgeDecision { + const targets = state.blocks.filter((b) => b.active && b.tier === 1); + return { + shouldInject: true, + reason: "tier-2 distillation pending", + compressibleRanges: [], + contextUsage: 0.7, + tier: 2, + tierTargetBlocks: targets, + breakdown: { + usage: 0.7, + growth: 0, + growthReference: 0, + effectiveThreshold: 0, + nudgeGrowthTokens: 0, + growthFloor: 0, + hasPendingNudge: 1, + overLimit: 0, + emergencyOverride: 0, + pendingT1: targets.length, + pendingT2: 0, + pendingT3: 0, + }, + }; +} + +test("overview announces truncated block lists instead of hiding them (#221)", () => { + const state = makeSessionState(); + const report = buildStatusReport(state, [], defaultCountTokens); + assert.ok(report.includes("41 active"), "header keeps the full active count"); + const shownIds = state.blocks + .filter((b) => b.active) + .map((b) => b.blockId) + .filter((id) => report.includes(`${id} (`)); + assert.ok(shownIds.length < 41, "the list itself is capped below the full count"); + assert.match(report, /11 more blocks not shown/, "states the exact hidden count"); + assert.ok( + report.includes('scope:"compressed"'), + "points at the view that shows the full list", + ); +}); + +test("nudge target list vs status overview reconcile once truncation is announced (#221)", () => { + const state = makeSessionState(); + const decision = tier2Decision(state); + const nudge = renderNudgeText(decision); + assert.ok(nudge.text.includes("Target tier-1 blocks to distill (35)"), "nudge lists all 35 targets"); + for (const id of ["b1", "b20", "b35"]) { + assert.ok(nudge.text.includes(`${id} `), `nudge names ${id}`); + } + + const report = buildStatusReport(state, [], defaultCountTokens); + const t1Shown = state.blocks.filter((b) => b.active && b.tier === 1 && report.includes(`${b.blockId} (T1)`)).length; + assert.ok(t1Shown < 35, "size-sorted cap hides some T1 targets from the overview"); + assert.ok(report.includes("41 active")); + assert.match(report, /11 more blocks not shown/, "shown + announced-hidden reconciles with the nudge count"); + assert.match(report, /T1: .*\(35 blocks\)/); + assert.match(report, /T2: .*\(6 blocks\)/); +}); + +test("overview does not announce truncation when the list fits", () => { + const state: CompressionState = { + ...createInitialState(), + blocks: [block({ blockId: "b1" }), block({ blockId: "b2" })], + }; + const report = buildStatusReport(state, [], defaultCountTokens); + assert.ok(!report.includes("more blocks not shown")); +});