mirror of
https://github.com/earendil-works/pi.git
synced 2026-10-02 00:35:27 +08:00
feat(coding-agent): show compaction usage notices
This commit is contained in:
@@ -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)).
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user