From 1cf436b8bfa892538eabd906055f15602daba23d Mon Sep 17 00:00:00 2001 From: yintianan Date: Sat, 22 Aug 2026 21:33:40 +0800 Subject: [PATCH 1/3] chore: ignore worktrees/ directory --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 975a5a1..b1a6aa1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ tmp/ .DS_Store coverage/ .acp-*.json +worktrees/ From e7e07a67062c4e7f020f9a3e4bb0dcbaef70454f Mon Sep 17 00:00:00 2001 From: yintianan Date: Sat, 22 Aug 2026 21:33:41 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(report):=20drilldown=20reverse/offset?= =?UTF-8?q?=20=E2=80=94=20reach=20newest=20messages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sort:time drilldown was ascending-only with slice(0,limit) head truncation, so the newest messages were unreachable in any sort once the conversation exceeded the limit (acp-kernel#93). Hosts without ref tags in the message stream (billion-context-dsh) rely on the drilldown as their only message-location entry, amplifying the gap. - StatusReportOptions: add reverse?: boolean (flip sorted order) and offset?: number (pagination), both backwards-compatible - renderMessageDrilldown + renderCompressedDrilldown: apply reverse after sorting, window shown rows with slice(offset, offset + limit) - footer: 'N of M shown.' stays for offset 0; offset > 0 prints 'Showing A–B of M.'; offset past end prints 'Offset N past end' instead of a silent empty page - overview Tip now advertises sort:"time" + reverse:true newest-first - tests: 7 drilldown cases (status-quo pin, reverse:time tail reach, offset pagination both directions, past-end, size reverse, tool filter + reverse, compressed scope) - README: buildStatusReport drilldown options table + example --- README.md | 29 +++++ src/report.ts | 49 +++++--- tests/report-drilldown.test.ts | 214 +++++++++++++++++++++++++++++++++ 3 files changed, 278 insertions(+), 14 deletions(-) create mode 100644 tests/report-drilldown.test.ts diff --git a/README.md b/README.md index 8f3d5f9..e8d60a3 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,35 @@ describes what the model originally saw. Nudge/pressure *decisions* are unaffected: they recount live text every turn. If you need the legacy live-recomputed tags, use `renderVisibleRefs` directly. +#### `buildStatusReport` drilldown — reaching the newest rows + +`buildStatusReport` accepts optional `scope`/`view`/`tool`/`sort`/`limit` +plus two navigation options: + +| Option | Default | Effect | +|--------|---------|--------| +| `reverse: boolean` | `false` | Flip the sorted order after sorting — `sort:"time"` + `reverse:true` lists newest-first, `sort:"size"` + `reverse:true` lists smallest-first | +| `offset: number` | `0` | Skip the first N rows of the sorted order (pagination with `limit`) | + +```ts +import { buildStatusReport } from "acp-kernel"; + +buildStatusReport(state, messages, countTokens, { + scope: "uncompressed", + view: "messages", + sort: "time", + reverse: true, // newest message first — reach the tail without a ref tag + limit: 30, +}); +// pagination: offset: 30 → the next page +``` + +Sort orders are otherwise unchanged (`time` ascending, `size` largest-first, +`tool` grouped, `age` most-survived-first for `scope:"compressed"`), so existing +callers keep working. The "N of M shown." footer becomes +"Showing A–B of M." when `offset > 0`, and reports "Offset N past end" when the +window starts beyond the list — no silent empty page. + ### Standalone modules | Module | Purpose | diff --git a/src/report.ts b/src/report.ts index cc53e23..68726c8 100644 --- a/src/report.ts +++ b/src/report.ts @@ -95,6 +95,10 @@ export interface StatusReportOptions { tool?: string; sort?: "size" | "time" | "tool" | "age"; limit?: number; + /** Flip the sorted order (newest/largest first). Default: false. */ + reverse?: boolean; + /** Skip the first N rows of the sorted order (pagination). Default: 0. */ + offset?: number; } export function buildStatusReport( @@ -108,20 +112,22 @@ export function buildStatusReport( const toolFilter = options.tool; const sort = options.sort ?? "size"; const limit = options.limit ?? 30; + const reverse = options.reverse ?? false; + const offset = Math.max(0, options.offset ?? 0); const activeBlocks = state.blocks .filter((b) => b.active) .sort((a, b) => numericPart(a.blockId) - numericPart(b.blockId)); if (scope === "compressed") { - return renderCompressedDrilldown(activeBlocks, state, sort, limit, countTokens); + return renderCompressedDrilldown(activeBlocks, state, sort, limit, countTokens, reverse, offset); } const { visible, summaryTokens } = collectVisible(messages, state, countTokens); if (scope === "uncompressed") { if (view === "messages") { - return renderMessageDrilldown(visible, toolFilter, sort, limit); + return renderMessageDrilldown(visible, toolFilter, sort, limit, reverse, offset); } return renderUncompressedRanges(visible); } @@ -196,7 +202,7 @@ function renderOverview( lines.push(""); lines.push( - `Tip: buildStatusReport({scope:"uncompressed", view:"messages", tool:"${topTool ?? "bash"}"}) for per-message listing`, + `Tip: buildStatusReport({scope:"uncompressed", view:"messages", sort:"time", reverse:true, tool:"${topTool ?? "bash"}"}) for newest-first per-message listing`, ); return lines.join("\n"); } @@ -240,11 +246,26 @@ function renderUncompressedRanges(visible: VisibleMessageInfo[]): string { return lines.join("\n"); } +function paginationNote(total: number, offset: number, shownCount: number): string[] { + if (total <= shownCount) return []; + const lines: string[] = [""]; + if (shownCount === 0) { + lines.push(`Offset ${offset} past end (${total} total).`); + } else if (offset > 0) { + lines.push(`Showing ${offset + 1}–${offset + shownCount} of ${total}.`); + } else { + lines.push(`${shownCount} of ${total} shown.`); + } + return lines; +} + function renderMessageDrilldown( visible: VisibleMessageInfo[], toolFilter: string | undefined, sort: string, limit: number, + reverse: boolean, + offset: number, ): string { let filtered = visible; if (toolFilter) filtered = filtered.filter((m) => m.tool === toolFilter); @@ -253,20 +274,19 @@ function renderMessageDrilldown( else if (sort === "tool") filtered.sort((a, b) => a.tool.localeCompare(b.tool) || b.tokens - a.tokens); else filtered.sort((a, b) => b.tokens - a.tokens); + if (reverse) filtered.reverse(); + const totalTokens = filtered.reduce((s, m) => s + m.tokens, 0); const allTokens = visible.reduce((s, m) => s + m.tokens, 0); const header = toolFilter ? `UNCOMPRESSED — ${toolFilter}: ${formatTokens(totalTokens)} | ${filtered.length} msgs | ${pct(totalTokens, allTokens)}% of visible` : `UNCOMPRESSED — ${formatTokens(totalTokens)} | ${filtered.length} msgs`; - const lines = [header, `Sorted by ${sort}`, ""]; - const shown = filtered.slice(0, limit); + const lines = [header, `Sorted by ${sort}${reverse ? " (reverse)" : ""}`, ""]; + const shown = filtered.slice(offset, offset + limit); for (const message of shown) { lines.push(` ${message.ref} (${formatTokens(message.tokens)}) ${message.tool}`); } - if (filtered.length > shown.length) { - lines.push(""); - lines.push(`${shown.length} of ${filtered.length} shown.`); - } + lines.push(...paginationNote(filtered.length, offset, shown.length)); return lines.join("\n"); } @@ -276,6 +296,8 @@ function renderCompressedDrilldown( sort: string, limit: number, countTokens: (t: string) => number, + reverse: boolean, + offset: number, ): string { let sorted = [...blocks]; if (sort === "time") sorted.sort((a, b) => a.createdAt - b.createdAt); @@ -288,6 +310,8 @@ function renderCompressedDrilldown( b.createdAt - a.createdAt, ); + if (reverse) sorted.reverse(); + const totalSummary = sorted.reduce((s, b) => s + summaryTokensOf(b, countTokens), 0); const totalEffective = sorted.reduce( (s, b) => s + effectiveCompressedTokens(b, state, countTokens), @@ -299,7 +323,7 @@ function renderCompressedDrilldown( const breakdown = tierBreakdown(sorted, countTokens); if (breakdown) lines.push(`Tier usage: ${breakdown}`); lines.push(""); - const shown = sorted.slice(0, limit); + const shown = sorted.slice(offset, offset + limit); for (const block of shown) { const nested = block.directBlockIds.length > 0 ? ` nested=[${block.directBlockIds.join(",")}]` : ""; const topic = block.topic ?? "(no topic)"; @@ -309,10 +333,7 @@ function renderCompressedDrilldown( ); lines.push(` "${topic}"`); } - if (sorted.length > shown.length) { - lines.push(""); - lines.push(`${shown.length} of ${sorted.length} shown.`); - } + lines.push(...paginationNote(sorted.length, offset, shown.length)); return lines.join("\n"); } diff --git a/tests/report-drilldown.test.ts b/tests/report-drilldown.test.ts new file mode 100644 index 0000000..693cab0 --- /dev/null +++ b/tests/report-drilldown.test.ts @@ -0,0 +1,214 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { buildStatusReport } from "../src/report.js"; +import { createInitialState } from "../src/state.js"; +import { defaultCountTokens } from "../src/tokenize.js"; +import type { CompressionBlock, CompressionState, CoreMessage } 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, + }; +} + +// messages m0..m9 (oldest..newest), each with a distinct token size so the +// fixture doubles for both sort:"time" and sort:"size" ordering assertions. +function fixtureMessages(): CoreMessage[] { + const messages: CoreMessage[] = []; + for (let i = 0; i < 10; i++) { + messages.push({ + id: `m${i}`, + role: "assistant", + contentType: "tool-result", + toolName: i % 2 === 0 ? "bash" : "read", + text: "x".repeat(i * 4 + 4), + }); + } + return messages; +} + +function fixtureState(messages: CoreMessage[]): CompressionState { + const byRaw: Record = {}; + const byRef: Record = {}; + messages.forEach((m, i) => { + const ref = `m${String(i).padStart(5, "0")}`; + byRaw[m.id] = ref; + byRef[ref] = m.id; + }); + return { + ...createInitialState(), + messageRefs: { byRaw, byRef }, + blocks: [], + }; +} + +test("drilldown sort:time is oldest-first and head-only by default (status quo pinned)", () => { + const messages = fixtureMessages(); + const state = fixtureState(messages); + const report = buildStatusReport(state, messages, defaultCountTokens, { + scope: "uncompressed", + view: "messages", + sort: "time", + limit: 3, + }); + assert.ok(report.includes(" m00000 (1) bash")); + assert.ok(report.includes(" m00001 (2) read")); + assert.ok(report.includes(" m00002 (3) bash")); + assert.ok(!report.includes("m00009"), "newest message must not appear with head-only default"); + assert.ok(report.includes("3 of 10 shown.")); +}); + +test("drilldown sort:time + reverse:true reaches the newest messages", () => { + const messages = fixtureMessages(); + const state = fixtureState(messages); + const report = buildStatusReport(state, messages, defaultCountTokens, { + scope: "uncompressed", + view: "messages", + sort: "time", + reverse: true, + limit: 3, + }); + assert.ok(report.includes("Sorted by time (reverse)")); + assert.ok(report.includes(" m00009 (10) read")); + assert.ok(report.includes(" m00008 (9) bash")); + assert.ok(report.includes(" m00007 (8) read")); + assert.ok(!report.includes("m00000"), "oldest message must not appear when reversed"); +}); + +test("drilldown offset paginates past the head of any sort", () => { + const messages = fixtureMessages(); + const state = fixtureState(messages); + // time, ascending, page 3 of 4 (rows 7-9) + const forward = buildStatusReport(state, messages, defaultCountTokens, { + scope: "uncompressed", + view: "messages", + sort: "time", + limit: 3, + offset: 6, + }); + assert.ok(forward.includes(" m00006 (7) bash")); + assert.ok(forward.includes(" m00008 (9) bash")); + assert.ok(forward.includes("Showing 7–9 of 10.")); + assert.ok(!forward.includes("m00009"), "offset window ends before the last message"); + + // time, descending, page 3 of 4 — the tail is now on page 1, later pages + // walk back toward the head + const backward = buildStatusReport(state, messages, defaultCountTokens, { + scope: "uncompressed", + view: "messages", + sort: "time", + reverse: true, + limit: 3, + offset: 6, + }); + assert.ok(backward.includes(" m00003 (4) read")); + assert.ok(backward.includes(" m00002 (3) bash")); + assert.ok(backward.includes(" m00001 (2) read")); + assert.ok(backward.includes("Showing 7–9 of 10.")); +}); + +test("drilldown offset past the end reports the miss instead of an empty page", () => { + const messages = fixtureMessages(); + const state = fixtureState(messages); + const report = buildStatusReport(state, messages, defaultCountTokens, { + scope: "uncompressed", + view: "messages", + sort: "time", + limit: 3, + offset: 10, + }); + assert.ok(report.includes("Offset 10 past end (10 total).")); + const body = report.replace(/^\s*$/gm, ""); + assert.ok(!body.includes("m0000"), "no message rows rendered past the end"); +}); + +test("drilldown sort:size reverse flips largest-first to smallest-first", () => { + const messages = fixtureMessages(); + const state = fixtureState(messages); + const largest = buildStatusReport(state, messages, defaultCountTokens, { + scope: "uncompressed", + view: "messages", + sort: "size", + limit: 2, + }); + assert.ok(largest.includes(" m00009 (10) read")); + assert.ok(largest.includes(" m00008 (9) bash")); + + const smallest = buildStatusReport(state, messages, defaultCountTokens, { + scope: "uncompressed", + view: "messages", + sort: "size", + reverse: true, + limit: 2, + }); + assert.ok(smallest.includes(" m00000 (1) bash")); + assert.ok(smallest.includes(" m00001 (2) read")); +}); + +test("drilldown tool filter combines with reverse:time to reach the newest rows of one tool", () => { + const messages = fixtureMessages(); + const state = fixtureState(messages); + const report = buildStatusReport(state, messages, defaultCountTokens, { + scope: "uncompressed", + view: "messages", + tool: "bash", + sort: "time", + reverse: true, + limit: 2, + }); + // newest bash rows are m8 (9), m6 (7) + assert.ok(report.includes(" m00008 (9) bash")); + assert.ok(report.includes(" m00006 (7) bash")); + assert.ok(!report.includes("read"), "tool filter must exclude other tools"); +}); + +test("drilldown compressed scope: reverse:time shows newest blocks first", () => { + const state: CompressionState = { + ...createInitialState(), + blocks: [ + block({ blockId: "b1", createdAt: 1000, summary: "old", topic: "one" }), + block({ blockId: "b2", createdAt: 2000, summary: "mid", topic: "two" }), + block({ blockId: "b3", createdAt: 3000, summary: "new", topic: "three" }), + ], + }; + const forward = buildStatusReport(state, [], defaultCountTokens, { + scope: "compressed", + sort: "time", + limit: 2, + }); + assert.ok(forward.includes("b1")); + assert.ok(forward.includes("b2")); + assert.ok(!forward.includes("b3"), "newest block must not appear with head-only default"); + assert.ok(forward.includes("2 of 3 shown.")); + + const backward = buildStatusReport(state, [], defaultCountTokens, { + scope: "compressed", + sort: "time", + reverse: true, + limit: 2, + }); + assert.ok(backward.includes("b3")); + assert.ok(backward.includes("b2")); + assert.ok(!backward.includes("b1"), "oldest block must not appear when reversed"); + + const paged = buildStatusReport(state, [], defaultCountTokens, { + scope: "compressed", + sort: "time", + limit: 1, + offset: 2, + }); + assert.ok(paged.includes("b3")); + assert.ok(!paged.includes("b1")); + assert.ok(paged.includes("Showing 3–3 of 3.")); +}); From 96501d4460f26f3aaafefd7a6fafb63823611073 Mon Sep 17 00:00:00 2001 From: yintianan Date: Sat, 22 Aug 2026 21:38:34 +0800 Subject: [PATCH 3/3] docs+test: align drilldown README with prose style; line-based past-end assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README: drop the options table, document reverse/offset in the same prose+example style as the renderTags section (option defaults inline, compatibility note last) - test: replace blank-line-strip body check with a line-level assertion (no ' m' rows after a past-end offset) — robust against future non-row ref mentions --- README.md | 25 +++++++++++-------------- tests/report-drilldown.test.ts | 5 +++-- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index e8d60a3..31ebd68 100644 --- a/README.md +++ b/README.md @@ -95,17 +95,15 @@ live-recomputed tags, use `renderVisibleRefs` directly. #### `buildStatusReport` drilldown — reaching the newest rows -`buildStatusReport` accepts optional `scope`/`view`/`tool`/`sort`/`limit` -plus two navigation options: - -| Option | Default | Effect | -|--------|---------|--------| -| `reverse: boolean` | `false` | Flip the sorted order after sorting — `sort:"time"` + `reverse:true` lists newest-first, `sort:"size"` + `reverse:true` lists smallest-first | -| `offset: number` | `0` | Skip the first N rows of the sorted order (pagination with `limit`) | +The per-message drilldown (`scope:"uncompressed"` + `view:"messages"`) sorts +`time` ascending by default and shows only the first `limit` rows, so the +newest messages are unreachable once the conversation outgrows the limit. +Two optional navigation options fix that: `reverse` (default `false`) flips +the sorted order after sorting — `sort:"time"` + `reverse:true` lists +newest-first, `sort:"size"` + `reverse:true` lists smallest-first — and +`offset` (default `0`) skips the first N rows, paginating with `limit`. ```ts -import { buildStatusReport } from "acp-kernel"; - buildStatusReport(state, messages, countTokens, { scope: "uncompressed", view: "messages", @@ -116,11 +114,10 @@ buildStatusReport(state, messages, countTokens, { // pagination: offset: 30 → the next page ``` -Sort orders are otherwise unchanged (`time` ascending, `size` largest-first, -`tool` grouped, `age` most-survived-first for `scope:"compressed"`), so existing -callers keep working. The "N of M shown." footer becomes -"Showing A–B of M." when `offset > 0`, and reports "Offset N past end" when the -window starts beyond the list — no silent empty page. +`reverse` and `offset` are optional, so existing call sites keep working +unchanged. The "N of M shown." footer stays for `offset: 0`; when +`offset > 0` it reads "Showing A–B of M.", and an offset past the end reports +"Offset N past end" instead of a silent empty page. ### Standalone modules diff --git a/tests/report-drilldown.test.ts b/tests/report-drilldown.test.ts index 693cab0..fce9756 100644 --- a/tests/report-drilldown.test.ts +++ b/tests/report-drilldown.test.ts @@ -129,8 +129,9 @@ test("drilldown offset past the end reports the miss instead of an empty page", offset: 10, }); assert.ok(report.includes("Offset 10 past end (10 total).")); - const body = report.replace(/^\s*$/gm, ""); - assert.ok(!body.includes("m0000"), "no message rows rendered past the end"); + // line-level check: no message row (two-space indent + ref) may be rendered + const rowLines = report.split("\n").filter((line) => /^ m\d/.test(line)); + assert.deepEqual(rowLines, [], "no message rows rendered past the end"); }); test("drilldown sort:size reverse flips largest-first to smallest-first", () => {