diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d64c60d22..e857000f5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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)). diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 585160806..c56340b1d 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -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 diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 9a744f027..22e72b8df 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -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; diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index 20c6b7586..1d626270e 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -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"], }, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 999220398..76c69b544 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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; +type CompactionCostNotice = { + type: "compaction_cost"; + kind: "compaction" | "branch_summary"; + usage: Usage; +}; + +type RenderSessionItem = AgentMessage | Extract | CompactionCostNotice; function isCustomSessionEntry(item: RenderSessionItem): item is Extract { 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, diff --git a/packages/coding-agent/test/interactive-mode-compaction.test.ts b/packages/coding-agent/test/interactive-mode-compaction.test.ts index dd019f8f4..ece2d43ca 100644 --- a/packages/coding-agent/test/interactive-mode-compaction.test.ts +++ b/packages/coding-agent/test/interactive-mode-compaction.test.ts @@ -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 }); });