Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ tmp/
.DS_Store
coverage/
.acp-*.json
worktrees/
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,32 @@ 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

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
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
```

`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

| Module | Purpose |
Expand Down
49 changes: 35 additions & 14 deletions src/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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);
}
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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);
Expand All @@ -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");
}

Expand All @@ -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);
Expand All @@ -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),
Expand All @@ -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)";
Expand All @@ -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");
}

Expand Down
215 changes: 215 additions & 0 deletions tests/report-drilldown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
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>): 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<string, string> = {};
const byRef: Record<string, string> = {};
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)."));
// 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", () => {
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."));
});
Loading