From dcccee67f9712546968c8fd7c68d8ec6f35e5028 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 23 Aug 2026 10:14:35 -0700 Subject: [PATCH] Flash when compaction actually folds turns away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operators could not tell from the TUI whether compaction folded context or no-op'd. Emit a short status flash only when the apply path records a summarized turn count — the same success gate as telemetry. --- src/session/runtime-assembly.test.ts | 34 ++++++++++++++++++++++++++++ src/session/runtime-assembly.ts | 3 +++ src/tui/product-host.ts | 10 ++++++++ src/tui/runner.ts | 2 ++ src/tui/runtime-channels.test.ts | 27 +++++++++++++++++++++- src/tui/runtime-notices.test.ts | 21 +++++++++++++++++ src/tui/runtime-notices.ts | 31 ++++++++++++++++++++++--- 7 files changed, 124 insertions(+), 4 deletions(-) diff --git a/src/session/runtime-assembly.test.ts b/src/session/runtime-assembly.test.ts index 60d05285c..4097bc2f8 100644 --- a/src/session/runtime-assembly.test.ts +++ b/src/session/runtime-assembly.test.ts @@ -285,4 +285,38 @@ describe("createSessionPruningCompactor", () => { await llm.apply(turns as never, { state: {} as never, trigger: "test" }); expect(captured).toBe(ctx); }); + + test("onFolded fires only when turns were actually folded", async () => { + const folds: { turnsBefore: number; turnsAfter: number }[] = []; + const summarize = async () => "summary"; + const folding = createSessionPruningCompactor({ + compactionMode: "llm", + summarize, + onFolded: (info) => folds.push(info), + }); + const now = Date.now(); + const many = Array.from({ length: 8 }, (_, i) => ({ + role: i % 2 === 0 ? "user" : "assistant", + content: [{ type: "text", text: `t${i}` }], + timestamp: now, + })); + await folding.apply(many as never, { state: {} as never, trigger: "test" }); + expect(folds).toHaveLength(1); + expect(folds[0]?.turnsBefore).toBe(8); + expect(folds[0]?.turnsAfter).toBeLessThan(8); + + const silent: { turnsBefore: number; turnsAfter: number }[] = []; + const noop = createSessionPruningCompactor({ + compactionMode: "llm", + summarize, + onFolded: (info) => silent.push(info), + }); + const few = Array.from({ length: 3 }, (_, i) => ({ + role: i % 2 === 0 ? "user" : "assistant", + content: [{ type: "text", text: `t${i}` }], + timestamp: now, + })); + await noop.apply(few as never, { state: {} as never, trigger: "test" }); + expect(silent).toEqual([]); + }); }); diff --git a/src/session/runtime-assembly.ts b/src/session/runtime-assembly.ts index 7220c44ff..e2d2790a7 100644 --- a/src/session/runtime-assembly.ts +++ b/src/session/runtime-assembly.ts @@ -268,6 +268,8 @@ export interface SessionPruningCompactorArgs { summarize: (turns: ConversationTurn[], ctx?: SummaryContext) => Promise; summaryContext?: () => SummaryContext | undefined; telemetry?: Telemetry; + /** Fires only when turns were actually folded away — not on no-ops. */ + onFolded?: (info: { turnsBefore: number; turnsAfter: number }) => void; } /** Shared pruning-compactor defaults for the main session agent. */ @@ -296,6 +298,7 @@ export function createSessionPruningCompactor(args: SessionPruningCompactorArgs) turns_before: turnsBefore, turns_after: result.output.length, }); + args.onFolded?.({ turnsBefore, turnsAfter: result.output.length }); } return result; }, diff --git a/src/tui/product-host.ts b/src/tui/product-host.ts index 1008ddf7c..839333e83 100644 --- a/src/tui/product-host.ts +++ b/src/tui/product-host.ts @@ -25,6 +25,8 @@ import { type ChromeLiveState, } from "./chrome-state.js"; import { + compactionFoldInfo, + compactionNotice, grantApproval, grantNotice, hookNotice, @@ -396,6 +398,7 @@ export async function mountProductHost(config: ProductHostConfig): Promise { summarize: compactionSummarize, summaryContext, telemetry: liveTelemetry, + // Main-session folds only — exec runner and subagents stay silent. + onFolded: (info) => emitter.emit("compaction", info), }), }, }); diff --git a/src/tui/runtime-channels.test.ts b/src/tui/runtime-channels.test.ts index b09784cd4..1b336cd38 100644 --- a/src/tui/runtime-channels.test.ts +++ b/src/tui/runtime-channels.test.ts @@ -193,6 +193,31 @@ describe("permission.grant channel", () => { }); }); +describe("compaction channel", () => { + test("a successful fold flashes before → after and holds no transcript row", async () => { + const { host, emitter, frame, cleanup } = await mountHeadless(); + try { + emitter.emit("compaction", { turnsBefore: 42, turnsAfter: 8 }); + const painted = await frame(); + expect(painted).toContain("context compacted · 42 → 8 turns"); + expect(host.shell.streamLog).toEqual([]); + } finally { + cleanup(); + } + }); + + test("a bad payload paints nothing", async () => { + const { host, emitter, frame, cleanup } = await mountHeadless(); + try { + emitter.emit("compaction", { turnsBefore: 42 }); + expect(await frame()).not.toContain("context compacted"); + expect(host.shell.streamLog).toEqual([]); + } finally { + cleanup(); + } + }); +}); + describe("agents chrome (live strip above the prompt)", () => { test("setChrome with running agents paints the agents zone", async () => { const { host, frame, cleanup } = await mountHeadless({ @@ -266,7 +291,7 @@ describe("every emitted runtime channel has a subscriber", () => { emitted.delete("subagent.progress"); test("the runner still emits the channels this suite knows about", () => { - for (const channel of ["hook", "mcp.status", "permission.grant"]) { + for (const channel of ["hook", "mcp.status", "permission.grant", "compaction"]) { expect([...emitted]).toContain(channel); } }); diff --git a/src/tui/runtime-notices.test.ts b/src/tui/runtime-notices.test.ts index a6dfc9ea2..ad3471eac 100644 --- a/src/tui/runtime-notices.test.ts +++ b/src/tui/runtime-notices.test.ts @@ -4,6 +4,8 @@ import { describe, expect, test } from "bun:test"; import { + compactionFoldInfo, + compactionNotice, grantApproval, grantNotice, hookNotice, @@ -104,6 +106,15 @@ describe("grantNotice", () => { }); }); +describe("compactionNotice", () => { + test("flashes before → after turn counts", () => { + expect(compactionNotice({ turnsBefore: 42, turnsAfter: 8 })).toEqual({ + kind: "flash", + text: "context compacted · 42 → 8 turns", + }); + }); +}); + describe("payload validation", () => { test("hook events that are not hook.updated are dropped", () => { expect(lifecycleHookEvent({ type: "hooks.loaded", hooks: [] })).toBeNull(); @@ -140,4 +151,14 @@ describe("payload validation", () => { }); expect(subAgentProgress({ description: "map callers" })).toBeNull(); }); + + test("compaction payloads require both turn counts and reject junk", () => { + expect(compactionFoldInfo({ turnsBefore: 42, turnsAfter: 8 })).toEqual({ + turnsBefore: 42, + turnsAfter: 8, + }); + expect(compactionFoldInfo({ turnsBefore: 42 })).toBeNull(); + expect(compactionFoldInfo(null)).toBeNull(); + expect(compactionFoldInfo("nope")).toBeNull(); + }); }); diff --git a/src/tui/runtime-notices.ts b/src/tui/runtime-notices.ts index 241549ee5..7aa421a36 100644 --- a/src/tui/runtime-notices.ts +++ b/src/tui/runtime-notices.ts @@ -1,6 +1,6 @@ /** - * Runtime side-channel notices: lifecycle hooks, MCP connection state and - * recorded permission grants. + * Runtime side-channel notices: lifecycle hooks, MCP connection state, + * recorded permission grants, and successful context compaction. * * These channels are chatter by default and only sometimes news. The split * this module encodes: @@ -9,7 +9,8 @@ * still be able to read after scrolling away (a hook that failed, an MCP * server asking for authorization or refusing to connect); * - a **flash** is for confirmation of something they just caused, true only - * for a moment (a hook that ran, a server that came up, a grant recorded); + * for a moment (a hook that ran, a server that came up, a grant recorded, + * a compaction that folded turns away); * - **null** is for inventory and intermediate states (`hooks.loaded`, a * server that is merely `connecting`) — the /hooks and /mcp panels own that. * @@ -108,6 +109,19 @@ export function grantNotice(approval: Approval): RuntimeNotice { }; } +export interface CompactionFoldInfo { + readonly turnsBefore: number; + readonly turnsAfter: number; +} + +/** Confirmation that context compaction actually folded turns away. */ +export function compactionNotice(info: CompactionFoldInfo): RuntimeNotice { + return { + kind: "flash", + text: `context compacted · ${info.turnsBefore} → ${info.turnsAfter} turns`, + }; +} + // --------------------------------------------------------------------------- // Emitter payload validation // --------------------------------------------------------------------------- @@ -168,6 +182,17 @@ export function grantApproval(raw: unknown): Approval | null { return parsed.approval as Approval; } +const compactionPayload = type({ + turnsBefore: "number", + turnsAfter: "number", +}); + +export function compactionFoldInfo(raw: unknown): CompactionFoldInfo | null { + const parsed = compactionPayload(raw); + if (parsed instanceof type.errors) return null; + return parsed; +} + export interface SubAgentProgress { readonly description: string; readonly toolName: string;