feat(coding-agent): show compaction usage notices

This commit is contained in:
Vegard Stikbakke
2026-08-18 13:27:00 +02:00
parent 2509b5c037
commit 836aee6d38
6 changed files with 202 additions and 11 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added transcript usage notices for compaction and branch summaries when cache miss notices are enabled.
### Fixed
- Fixed the subagent example repeatedly prompting before running project-local agents in trusted repositories ([#8261](https://github.com/earendil-works/pi/issues/8261)).
+1 -1
View File
@@ -31,7 +31,7 @@ Use `/trust` in interactive mode to save a project trust decision for future ses
| `defaultModel` | string | - | Default model ID |
| `defaultThinkingLevel` | string | - | `"off"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`, `"max"` |
| `hideThinkingBlock` | boolean | `false` | Hide thinking blocks in output |
| `showCacheMissNotices` | boolean | `false` | Show transcript notices for significant prompt-cache misses |
| `showCacheMissNotices` | boolean | `false` | Show transcript notices for significant prompt-cache misses and compaction or branch-summary usage |
| `thinkingBudgets` | object | - | Custom token budgets per thinking level |
#### thinkingBudgets
@@ -100,7 +100,7 @@ export interface Settings {
branchSummary?: BranchSummarySettings;
retry?: RetrySettings;
hideThinkingBlock?: boolean;
showCacheMissNotices?: boolean; // default: false - show transcript notices for significant prompt-cache misses
showCacheMissNotices?: boolean; // default: false - show prompt-cache miss and compaction cost notices
externalEditor?: string; // Command for Ctrl+G external editor; takes precedence over VISUAL/EDITOR
shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows); supports leading ~ expansion
quietStartup?: boolean;
@@ -549,7 +549,7 @@ export class SettingsSelectorComponent extends Container {
{
id: "cache-miss-notices",
label: "Cache miss notices",
description: "Show transcript notices for significant prompt-cache misses",
description: "Show transcript notices for significant prompt-cache misses and compaction costs",
currentValue: config.showCacheMissNotices ? "true" : "false",
values: ["true", "false"],
},
@@ -9,7 +9,7 @@ import * as os from "node:os";
import * as path from "node:path";
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import type { AuthEvent, AuthPrompt } from "@earendil-works/pi-ai";
import type { AssistantMessage, ImageContent, Message, Model } from "@earendil-works/pi-ai/compat";
import type { AssistantMessage, ImageContent, Message, Model, Usage } from "@earendil-works/pi-ai/compat";
import type {
AutocompleteItem,
AutocompleteProvider,
@@ -204,12 +204,22 @@ type CompactionQueuedMessage = {
mode: "steer" | "followUp";
};
type RenderSessionItem = AgentMessage | Extract<SessionEntry, { type: "custom" }>;
type CompactionCostNotice = {
type: "compaction_cost";
kind: "compaction" | "branch_summary";
usage: Usage;
};
type RenderSessionItem = AgentMessage | Extract<SessionEntry, { type: "custom" }> | CompactionCostNotice;
function isCustomSessionEntry(item: RenderSessionItem): item is Extract<SessionEntry, { type: "custom" }> {
return "type" in item && item.type === "custom";
}
function isCompactionCostNotice(item: RenderSessionItem): item is CompactionCostNotice {
return "type" in item && item.type === "compaction_cost";
}
const DEAD_TERMINAL_ERROR_CODES = new Set(["EIO", "EPIPE", "ENOTCONN"]);
function isDeadTerminalError(error: unknown): boolean {
@@ -3349,8 +3359,13 @@ export class InteractiveMode {
this.showStatus("Auto-compaction cancelled");
}
} else if (event.result) {
const entries = this.sessionManager.buildContextEntries();
if (entries[0]?.type !== "compaction") {
throw new Error("Completed compaction is missing from the session context");
}
this.chatContainer.clear();
this.rebuildChatFromMessages();
// The latest compaction is prepended for model context; append it below at its chronological position.
this.renderSessionEntries(entries.slice(1));
this.addMessageToChat(
createCompactionSummaryMessage(
event.result.summary,
@@ -3358,6 +3373,13 @@ export class InteractiveMode {
new Date().toISOString(),
),
);
if (event.result.usage) {
this.addCompactionCostNotice({
type: "compaction_cost",
kind: "compaction",
usage: event.result.usage,
});
}
this.footer.invalidate();
} else if (event.errorMessage) {
if (event.reason === "manual") {
@@ -3629,6 +3651,10 @@ export class InteractiveMode {
this.addCustomEntryToChat(item);
continue;
}
if (isCompactionCostNotice(item)) {
this.addCompactionCostNotice(item);
continue;
}
const message = item;
// Assistant messages need special handling for tool calls
@@ -3706,11 +3732,32 @@ export class InteractiveMode {
if (entry.type === "custom") {
return [entry];
}
return sessionEntryToContextMessages(entry);
const messages = sessionEntryToContextMessages(entry);
if ((entry.type === "compaction" || entry.type === "branch_summary") && entry.usage && messages.length > 0) {
return [...messages, { type: "compaction_cost", kind: entry.type, usage: entry.usage }];
}
return messages;
});
this.renderSessionItems(items, options);
}
/**
* Render billing usage for a compaction or branch summary. The notice is derived
* from persisted summary usage and is not stored as a separate session entry.
*/
private addCompactionCostNotice(notice: CompactionCostNotice): void {
if (!this.settingsManager.getShowCacheMissNotices()) return;
const { usage } = notice;
const tokens = usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
const cost = usage.cost.total >= 0.01 ? ` (~$${usage.cost.total.toFixed(2)})` : "";
const label = notice.kind === "compaction" ? "Compaction" : "Branch summary";
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(
new Text(theme.fg("warning", `${label}: ${formatTokens(tokens)} tokens billed${cost}`), 1, 0),
);
}
/**
* Show a transcript notice when a completed assistant message paid for a
* significant cache miss. Only states observable facts: the miss itself,
@@ -1,8 +1,140 @@
import type { Usage } from "@earendil-works/pi-ai";
import { Container } from "@earendil-works/pi-tui";
import { describe, expect, test, vi } from "vitest";
import type { SessionEntry } from "../src/core/session-manager.ts";
import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts";
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
import { stripAnsi } from "../src/utils/ansi.ts";
describe("InteractiveMode compaction events", () => {
test("rebuilds chat and appends a synthetic compaction summary at the bottom", async () => {
test("uses the cache miss notice setting for compaction and branch summary costs", () => {
const usage: Usage = {
input: 10,
output: 20,
cacheRead: 30,
cacheWrite: 40,
totalTokens: 100,
cost: { input: 0.01, output: 0.02, cacheRead: 0.03, cacheWrite: 0.065, total: 0.125 },
};
const addCompactionCostNotice = Reflect.get(InteractiveMode.prototype, "addCompactionCostNotice") as (
this: { chatContainer: Container; settingsManager: { getShowCacheMissNotices(): boolean } },
notice: {
type: "compaction_cost";
kind: "compaction" | "branch_summary";
usage: Usage;
},
) => void;
initTheme("dark");
const enabled = {
chatContainer: new Container(),
settingsManager: { getShowCacheMissNotices: () => true },
};
addCompactionCostNotice.call(enabled, { type: "compaction_cost", kind: "compaction", usage });
addCompactionCostNotice.call(enabled, {
type: "compaction_cost",
kind: "branch_summary",
usage,
});
const output = stripAnsi(enabled.chatContainer.render(120).join("\n"));
expect(output).toContain("Compaction: 100 tokens billed (~$0.13)");
expect(output).toContain("Branch summary: 100 tokens billed (~$0.13)");
const disabled = {
chatContainer: new Container(),
settingsManager: { getShowCacheMissNotices: () => false },
};
addCompactionCostNotice.call(disabled, { type: "compaction_cost", kind: "compaction", usage });
expect(disabled.chatContainer.children).toHaveLength(0);
});
test("renders each compaction cost after its summary", () => {
const currentUsage: Usage = {
input: 10,
output: 20,
cacheRead: 30,
cacheWrite: 40,
totalTokens: 100,
cost: { input: 0.01, output: 0.02, cacheRead: 0.03, cacheWrite: 0.04, total: 0.1 },
};
const previousUsage: Usage = {
input: 1,
output: 2,
cacheRead: 3,
cacheWrite: 4,
totalTokens: 10,
cost: { input: 0.001, output: 0.002, cacheRead: 0.003, cacheWrite: 0.004, total: 0.01 },
};
const entries: SessionEntry[] = [
{
type: "compaction",
id: "current",
parentId: "previous",
timestamp: "2025-01-02T00:00:00Z",
summary: "current summary",
firstKeptEntryId: "kept",
tokensBefore: 200,
usage: currentUsage,
},
{
type: "compaction",
id: "previous",
parentId: null,
timestamp: "2025-01-01T00:00:00Z",
summary: "previous summary",
firstKeptEntryId: "kept",
tokensBefore: 100,
usage: previousUsage,
},
];
const fakeThis = { renderSessionItems: vi.fn() };
const renderSessionEntries = Reflect.get(InteractiveMode.prototype, "renderSessionEntries") as (
this: typeof fakeThis,
entries: SessionEntry[],
) => void;
renderSessionEntries.call(fakeThis, entries);
expect(fakeThis.renderSessionItems).toHaveBeenCalledWith(
[
expect.objectContaining({ role: "compactionSummary", summary: "current summary" }),
{ type: "compaction_cost", kind: "compaction", usage: currentUsage },
expect.objectContaining({ role: "compactionSummary", summary: "previous summary" }),
{ type: "compaction_cost", kind: "compaction", usage: previousUsage },
],
{},
);
});
test("renders retained entries and appends the latest summary cost at the bottom", async () => {
const usage: Usage = {
input: 10,
output: 20,
cacheRead: 30,
cacheWrite: 40,
totalTokens: 100,
cost: { input: 0.01, output: 0.02, cacheRead: 0.03, cacheWrite: 0.065, total: 0.125 },
};
const latestCompaction: SessionEntry = {
type: "compaction",
id: "latest",
parentId: "previous",
timestamp: "2025-01-02T00:00:00Z",
summary: "summary",
firstKeptEntryId: "kept",
tokensBefore: 123,
usage,
};
const previousCompaction: SessionEntry = {
type: "compaction",
id: "previous",
parentId: null,
timestamp: "2025-01-01T00:00:00Z",
summary: "previous summary",
firstKeptEntryId: "kept",
tokensBefore: 100,
usage,
};
const fakeThis = {
isInitialized: true,
footer: { invalidate: vi.fn() },
@@ -11,8 +143,10 @@ describe("InteractiveMode compaction events", () => {
defaultEditor: {},
statusContainer: { clear: vi.fn() },
chatContainer: { clear: vi.fn() },
rebuildChatFromMessages: vi.fn(),
sessionManager: { buildContextEntries: vi.fn().mockReturnValue([latestCompaction, previousCompaction]) },
renderSessionEntries: vi.fn(),
addMessageToChat: vi.fn(),
addCompactionCostNotice: vi.fn(),
showError: vi.fn(),
showStatus: vi.fn(),
clearStatusIndicator: vi.fn(),
@@ -26,7 +160,7 @@ describe("InteractiveMode compaction events", () => {
event: {
type: "compaction_end";
reason: "manual" | "threshold" | "overflow";
result: { tokensBefore: number; summary: string } | undefined;
result: { tokensBefore: number; summary: string; usage?: Usage } | undefined;
aborted: boolean;
willRetry: boolean;
errorMessage?: string;
@@ -39,13 +173,14 @@ describe("InteractiveMode compaction events", () => {
result: {
tokensBefore: 123,
summary: "summary",
usage,
},
aborted: false,
willRetry: false,
});
expect(fakeThis.chatContainer.clear).toHaveBeenCalledTimes(1);
expect(fakeThis.rebuildChatFromMessages).toHaveBeenCalledTimes(1);
expect(fakeThis.renderSessionEntries).toHaveBeenCalledWith([previousCompaction]);
expect(fakeThis.addMessageToChat).toHaveBeenCalledTimes(1);
expect(fakeThis.addMessageToChat).toHaveBeenCalledWith(
expect.objectContaining({
@@ -54,6 +189,11 @@ describe("InteractiveMode compaction events", () => {
summary: "summary",
}),
);
expect(fakeThis.addCompactionCostNotice).toHaveBeenCalledWith({
type: "compaction_cost",
kind: "compaction",
usage,
});
expect(fakeThis.flushCompactionQueue).toHaveBeenCalledWith({ willRetry: false });
});