From 7fecd7deee855dc932119b785569d8708f9bc36d Mon Sep 17 00:00:00 2001 From: ework-agent Date: Wed, 26 Aug 2026 14:16:29 +0800 Subject: [PATCH] fix(report): group acp_status uncompressed ranges by conversation turn renderUncompressedRanges collapsed the whole visible window into one giant range whenever refs were dense, because it only merged consecutive ref numbers. Split ranges at user-message boundaries once a group has >= 3 messages (same condition as buildCompressibleRanges), sort ranges by size by default (sort:"time" keeps chronological order), and honor the limit option instead of a hardcoded 30. --- src/report.ts | 32 +++++++++++------ tests/decompress-report.test.ts | 61 +++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 10 deletions(-) diff --git a/src/report.ts b/src/report.ts index cc53e23..39f745a 100644 --- a/src/report.ts +++ b/src/report.ts @@ -61,6 +61,7 @@ interface VisibleMessageInfo { tokens: number; tool: string; index: number; + isUser: boolean; } function collectVisible( @@ -84,7 +85,7 @@ function collectVisible( if (!ref) return; const tokens = countTokens(message.text ?? ""); const tool = message.toolName ?? "text"; - if (tokens > 0) visible.push({ ref, tokens, tool, index }); + if (tokens > 0) visible.push({ ref, tokens, tool, index, isUser: message.role === "user" }); }); return { visible, summaryTokens }; } @@ -123,7 +124,7 @@ export function buildStatusReport( if (view === "messages") { return renderMessageDrilldown(visible, toolFilter, sort, limit); } - return renderUncompressedRanges(visible); + return renderUncompressedRanges(visible, sort, limit); } return renderOverview(visible, summaryTokens, activeBlocks, state, countTokens, limit); @@ -201,7 +202,7 @@ function renderOverview( return lines.join("\n"); } -function renderUncompressedRanges(visible: VisibleMessageInfo[]): string { +function renderUncompressedRanges(visible: VisibleMessageInfo[], sort: string, limit: number): string { const lines: string[] = []; const totalTokens = visible.reduce((s, m) => s + m.tokens, 0); lines.push(`UNCOMPRESSED — ${formatTokens(totalTokens)} | ${visible.length} visible messages`); @@ -210,9 +211,11 @@ function renderUncompressedRanges(visible: VisibleMessageInfo[]): string { lines.push(" (no uncompressed messages)"); return lines.join("\n"); } - // Merge consecutive messages into ranges (by numeric ref), aggregating - // token counts and dominant tool so the view reads as blocks, not a - // per-message firehose — mirroring the Compressible Ranges output. + // Group messages into ranges: merge consecutive refs, but split at + // user-message boundaries once a group has >= 3 messages — the same + // condition buildCompressibleRanges uses (recommend.ts), so each range + // aligns to roughly one conversation turn instead of collapsing the + // whole visible window into one giant range when refs are dense. interface Merged { startRef: string; endRef: string; startNum: number; count: number; tokens: number; tool: string; } const refNum = (ref: string): number => { const m = ref.match(/\d+/); @@ -222,7 +225,9 @@ function renderUncompressedRanges(visible: VisibleMessageInfo[]): string { for (const m of visible) { const num = refNum(m.ref); const last = merged[merged.length - 1]; - if (last && num === last.startNum + last.count) { + const contiguous = !!last && num === last.startNum + last.count; + const turnBoundary = !!last && m.isUser && last.count >= 3; + if (contiguous && !turnBoundary) { last.endRef = m.ref; last.count += 1; last.tokens += m.tokens; @@ -230,12 +235,19 @@ function renderUncompressedRanges(visible: VisibleMessageInfo[]): string { merged.push({ startRef: m.ref, endRef: m.ref, startNum: num, count: 1, tokens: m.tokens, tool: m.tool }); } } - for (const r of merged.slice(0, 30)) { + // Largest ranges first by default so the view directly answers "what + // should I compress first" (matches the default sort and the tool's + // documented purpose); sort:"time" keeps chronological order. + if (sort !== "time") merged.sort((a, b) => b.tokens - a.tokens || a.startNum - b.startNum); + lines.push(`Sorted by ${sort === "time" ? "time" : "size"}`); + lines.push(""); + const shown = merged.slice(0, limit); + for (const r of shown) { const range = r.count === 1 ? r.startRef : `${r.startRef}–${r.endRef}`; lines.push(` ${range} (${r.count} msgs, ${formatTokens(r.tokens)}${r.count > 1 ? ` (${Math.round(r.tokens / r.count)}/msg)` : ""}) ${r.tool}`); } - if (merged.length > 30) { - lines.push(` ... and ${merged.length - 30} more ranges`); + if (merged.length > shown.length) { + lines.push(` ... and ${merged.length - shown.length} more ranges`); } return lines.join("\n"); } diff --git a/tests/decompress-report.test.ts b/tests/decompress-report.test.ts index b2ab0fc..f7c65a5 100644 --- a/tests/decompress-report.test.ts +++ b/tests/decompress-report.test.ts @@ -134,6 +134,67 @@ test("buildStatusReport compressed scope lists blocks with details", () => { assert.ok(report.includes('"deploy"')); }); +function turnState(): CompressionState { + const byRaw: Record = {}; + for (let i = 1; i <= 8; i++) byRaw[`r${i}`] = `m0000${i}`; + return { + ...createInitialState(), + blocks: [], + messageRefs: { byRaw, byRef: {} }, + }; +} + +function turnMessages(): CoreMessage[] { + const words = (n: number) => Array.from({ length: n }, (_, i) => `w${i}`).join(" "); + return [ + { id: "r1", role: "user", contentType: "text", text: "start turn one" }, + { id: "r2", role: "assistant", contentType: "tool-result", toolName: "bash", text: words(4) }, + { id: "r3", role: "assistant", contentType: "text", text: "done one" }, + { id: "r4", role: "user", contentType: "text", text: "start turn two" }, + { id: "r5", role: "assistant", contentType: "tool-result", toolName: "bash", text: words(4) }, + { id: "r6", role: "assistant", contentType: "text", text: "done two" }, + { id: "r7", role: "user", contentType: "text", text: "start turn three" }, + { id: "r8", role: "assistant", contentType: "text", text: words(20) }, + ]; +} + +const wordCount = (t: string) => (t.trim() ? t.split(/\s+/).length : 0); + +test("buildStatusReport uncompressed ranges splits at user-message turn boundaries", () => { + const report = buildStatusReport(turnState(), turnMessages(), wordCount, { scope: "uncompressed" }); + assert.ok(!report.includes("m00001–m00008"), "dense refs must not collapse into one giant range"); + assert.ok(report.includes("m00001–m00003")); + assert.ok(report.includes("m00004–m00006")); + assert.ok(report.includes("m00007–m00008")); +}); + +test("buildStatusReport uncompressed ranges sorts by size by default", () => { + const report = buildStatusReport(turnState(), turnMessages(), wordCount, { scope: "uncompressed" }); + assert.ok(report.includes("Sorted by size")); + const i3 = report.indexOf("m00007–m00008"); + const i1 = report.indexOf("m00001–m00003"); + const i2 = report.indexOf("m00004–m00006"); + assert.ok(i3 !== -1 && i1 !== -1 && i2 !== -1); + assert.ok(i3 < i1, "largest turn (23 tokens) first"); + assert.ok(i1 < i2, "tie broken by earlier start ref"); +}); + +test("buildStatusReport uncompressed ranges sort=time keeps chronological order", () => { + const report = buildStatusReport(turnState(), turnMessages(), wordCount, { scope: "uncompressed", sort: "time" }); + assert.ok(report.includes("Sorted by time")); + const i1 = report.indexOf("m00001–m00003"); + const i3 = report.indexOf("m00007–m00008"); + assert.ok(i1 < i3); +}); + +test("buildStatusReport uncompressed ranges respects limit", () => { + const report = buildStatusReport(turnState(), turnMessages(), wordCount, { scope: "uncompressed", limit: 2 }); + assert.ok(report.includes("m00007–m00008")); + assert.ok(report.includes("m00001–m00003")); + assert.ok(!report.includes("m00004–m00006")); + assert.ok(report.includes("... and 1 more ranges")); +}); + test("buildRecap lists all active blocks when no blockId", () => { const state: CompressionState = { ...createInitialState(),