Skip to content
Merged
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
34 changes: 34 additions & 0 deletions src/session/runtime-assembly.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
});
3 changes: 3 additions & 0 deletions src/session/runtime-assembly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,8 @@ export interface SessionPruningCompactorArgs {
summarize: (turns: ConversationTurn[], ctx?: SummaryContext) => Promise<string>;
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. */
Expand Down Expand Up @@ -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;
},
Expand Down
10 changes: 10 additions & 0 deletions src/tui/product-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {
type ChromeLiveState,
} from "./chrome-state.js";
import {
compactionFoldInfo,
compactionNotice,
grantApproval,
grantNotice,
hookNotice,
Expand Down Expand Up @@ -396,6 +398,7 @@ export async function mountProductHost(config: ProductHostConfig): Promise<Produ
config.eventEmitter.off("hook", onHook);
config.eventEmitter.off("mcp.status", onMcpStatus);
config.eventEmitter.off("permission.grant", onPermissionGrant);
config.eventEmitter.off("compaction", onCompaction);
bridge.dispose();
// Cancels any flash still counting down: its expiry repaints, and after
// teardown that repaint reaches a destroyed text buffer.
Expand Down Expand Up @@ -462,6 +465,12 @@ export async function mountProductHost(config: ProductHostConfig): Promise<Produ
if (approval !== null) show(grantNotice(approval));
}

function onCompaction(payload: unknown): void {
if (disposed) return;
const info = compactionFoldInfo(payload);
if (info !== null) show(compactionNotice(info));
}

// The renderer already owns the alternate screen and raw mode by this point,
// but `dispose` has not been handed to any caller yet — a throw here would
// leave the terminal wedged with nobody able to restore it.
Expand Down Expand Up @@ -616,6 +625,7 @@ export async function mountProductHost(config: ProductHostConfig): Promise<Produ
config.eventEmitter.on("hook", onHook);
config.eventEmitter.on("mcp.status", onMcpStatus);
config.eventEmitter.on("permission.grant", onPermissionGrant);
config.eventEmitter.on("compaction", onCompaction);

return {
shell,
Expand Down
2 changes: 2 additions & 0 deletions src/tui/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1508,6 +1508,8 @@ export async function runTUI(initialConfig: Config): Promise<number> {
summarize: compactionSummarize,
summaryContext,
telemetry: liveTelemetry,
// Main-session folds only — exec runner and subagents stay silent.
onFolded: (info) => emitter.emit("compaction", info),
}),
},
});
Expand Down
27 changes: 26 additions & 1 deletion src/tui/runtime-channels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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);
}
});
Expand Down
21 changes: 21 additions & 0 deletions src/tui/runtime-notices.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import { describe, expect, test } from "bun:test";

import {
compactionFoldInfo,
compactionNotice,
grantApproval,
grantNotice,
hookNotice,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
});
});
31 changes: 28 additions & 3 deletions src/tui/runtime-notices.ts
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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.
*
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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;
Expand Down
Loading