diff --git a/packages/coding-agent/.changes/res-1258-agents-view-costs.md b/packages/coding-agent/.changes/res-1258-agents-view-costs.md new file mode 100644 index 0000000000..793d28dd3c --- /dev/null +++ b/packages/coding-agent/.changes/res-1258-agents-view-costs.md @@ -0,0 +1 @@ +- Added token and cost details to agents view rows: input/output tokens plus the session's own cost and its recursive total including all subagents; the message-count detail is gone. diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index f925c72ec1..9289bafe53 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -280,7 +280,7 @@ import { type BashOperations, createLocalBashOperations } from "./tools/bash.js" import { createAllToolDefinitions } from "./tools/index.js"; import { IpythonKernelProvisioner } from "./tools/ipython.js"; import { createToolDefinitionFromAgentTool } from "./tools/tool-definition-wrapper.js"; -import { addAssistantUsage, emptyUsage } from "./usage.js"; +import { addAssistantUsage, emptyUsage, type SessionUsageSummary, sessionUsageSummaryFrom } from "./usage.js"; import { SERPER_CREDENTIAL_ID, SERPER_ENV_VAR, WEBSEARCH_SKILL_NAME } from "./websearch-credential.js"; export type { GoalState, GoalStatus } from "./goals.js"; @@ -7565,6 +7565,7 @@ export class AgentSession { let firstKeptEntryId: string; let tokensBefore: number; let details: CompactionResult["details"]; + let usage: CompactionResult["usage"]; try { if (this._extensionRunner.hasHandlers("session_before_compact")) { const result = (await this._extensionRunner.emit({ @@ -7586,7 +7587,7 @@ export class AgentSession { } if (extensionCompaction) { - ({ summary, firstKeptEntryId, tokensBefore, details } = extensionCompaction); + ({ summary, firstKeptEntryId, tokensBefore, details, usage } = extensionCompaction); } else { // Each summary wire call gets its own request ID: split turns send two // different bodies, and one Idempotency-Key must never cover both. A slice @@ -7615,7 +7616,7 @@ export class AgentSession { throw error; } }; - ({ summary, firstKeptEntryId, tokensBefore, details } = await compact( + ({ summary, firstKeptEntryId, tokensBefore, details, usage } = await compact( preparation, model, apiKey, @@ -7647,6 +7648,7 @@ export class AgentSession { details, fromExtension, customInstructions, + usage, ); } catch (error) { compactionSettled = true; @@ -11732,6 +11734,7 @@ export class AgentSession { let summaryText: string | undefined; let summaryDetails: unknown; + let summaryUsage: Usage | undefined; if (options.summarize && entriesToSummarize.length > 0 && !extensionSummary) { const model = this.model!; const { apiKey, headers } = await this._getRequiredRequestAuth(model); @@ -11752,6 +11755,7 @@ export class AgentSession { throw new Error(result.error); } summaryText = result.summary; + summaryUsage = result.usage; summaryDetails = { readFiles: result.readFiles || [], modifiedFiles: result.modifiedFiles || [], @@ -11787,6 +11791,7 @@ export class AgentSession { summaryText, summaryDetails, fromExtension, + summaryUsage, ); summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry; @@ -11957,6 +11962,22 @@ export class AgentSession { return (provider, modelId) => this._modelRegistry.find(provider, modelId)?.contextWindow; } + private _ownUsageMemo?: { count: number; tailId: string | undefined; usage: SessionUsageSummary | undefined }; + + // Whole-file own spend, identical to the catalog scan so rows never shift at passivation. + getOwnUsageSummary(): SessionUsageSummary | undefined { + const entries = this.sessionManager.getEntries(); + const tailId = entries.at(-1)?.id; + const memo = this._ownUsageMemo; + if (memo && memo.count === entries.length && memo.tailId === tailId) { + return memo.usage; + } + const { ownUsage } = computeOwnAndTotalUsage(entries, entries); + const usage = sessionUsageSummaryFrom(ownUsage); + this._ownUsageMemo = { count: entries.length, tailId, usage }; + return usage; + } + /** * Build the agent context overview for /context: this session as the root * plus one node per RLM sub-agent, recursively. Running children are read diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts index a23f825f05..3917a45d11 100644 --- a/packages/coding-agent/src/core/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -6,7 +6,7 @@ */ import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { Model } from "@earendil-works/pi-ai"; +import type { Model, Usage } from "@earendil-works/pi-ai"; import { completeSimple } from "@earendil-works/pi-ai"; import { convertToLlm, @@ -31,6 +31,7 @@ export interface BranchSummaryResult { modifiedFiles?: string[]; aborted?: boolean; error?: string; + usage?: Usage; } /** Details stored in BranchSummaryEntry.details for file tracking */ @@ -303,5 +304,6 @@ export async function generateBranchSummary( summary: summary || "No summary generated", readFiles, modifiedFiles, + usage: response.usage, }; } diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index a11dae6b95..a47152b350 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -15,6 +15,7 @@ import { createCustomMessage, } from "../messages.js"; import { buildSessionContext, type CompactionEntry, type SessionEntry } from "../session-manager.js"; +import { addAssistantUsage, emptyUsage } from "../usage.js"; import { computeFileLists, createFileOps, @@ -30,6 +31,11 @@ export interface CompactionDetails { modifiedFiles: string[]; } +export interface SummarySlice { + summary: string; + usage?: Usage; +} + /** * Extract file operations from messages and previous compaction entries. */ @@ -98,6 +104,8 @@ export interface CompactionResult { tokensBefore: number; /** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */ details?: T; + /** What the summarization call(s) billed; persisted on the compaction entry. */ + usage?: Usage; } export const COMPACT_SKILL_NAME = "compact"; @@ -515,7 +523,7 @@ export async function generateSummary( customInstructions?: string, previousSummary?: string, thinkingLevel?: ThinkingLevel, -): Promise { +): Promise { const maxTokens = Math.floor(0.8 * reserveTokens); const basePrompt = buildSummarizationPrompt(customInstructions, previousSummary); @@ -556,7 +564,7 @@ export async function generateSummary( .map((c) => c.text) .join("\n"); - return textContent; + return { summary: textContent, usage: response.usage }; } export interface CompactionPreparation { /** UUID of first entry to keep */ @@ -696,6 +704,7 @@ export async function compact( settings, } = preparation; let summary: string; + const slices: SummarySlice[] = []; if (isSplitTurn && turnPrefixMessages.length > 0) { // Split turns make two wire calls with different bodies; each needs its own identity. @@ -714,7 +723,7 @@ export async function compact( thinkingLevel, ), ) - : Promise.resolve("No prior history."), + : Promise.resolve({ summary: "No prior history." }), summaryCall((callHeaders) => generateTurnPrefixSummary( turnPrefixMessages, @@ -727,9 +736,10 @@ export async function compact( ), ), ]); - summary = `${historyResult}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult}`; + slices.push(historyResult, turnPrefixResult); + summary = `${historyResult.summary}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.summary}`; } else { - summary = await summaryCall((callHeaders) => + const result = await summaryCall((callHeaders) => generateSummary( messagesToSummarize, model, @@ -742,6 +752,8 @@ export async function compact( thinkingLevel, ), ); + slices.push(result); + summary = result.summary; } const { readFiles, modifiedFiles } = computeFileLists(fileOps); summary += formatFileOperations(readFiles, modifiedFiles); @@ -750,11 +762,18 @@ export async function compact( throw new Error("First kept entry has no UUID - session may need migration"); } + let usage: Usage | undefined; + for (const slice of slices) { + if (!slice.usage) continue; + usage ??= emptyUsage(); + addAssistantUsage(usage, slice.usage); + } return { summary, firstKeptEntryId, tokensBefore, details: { readFiles, modifiedFiles } as CompactionDetails, + usage, }; } @@ -769,7 +788,7 @@ async function generateTurnPrefixSummary( headers?: Record, signal?: AbortSignal, thinkingLevel?: ThinkingLevel, -): Promise { +): Promise { const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix const llmMessages = convertToLlm(messages); const conversationText = serializeConversation(llmMessages); @@ -794,8 +813,11 @@ async function generateTurnPrefixSummary( throw new Error(`Turn prefix summarization failed: ${response.errorMessage || "Unknown error"}`); } - return response.content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join("\n"); + return { + summary: response.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join("\n"), + usage: response.usage, + }; } diff --git a/packages/coding-agent/src/core/context-tree.ts b/packages/coding-agent/src/core/context-tree.ts index 6e744b9929..7546f8679c 100644 --- a/packages/coding-agent/src/core/context-tree.ts +++ b/packages/coding-agent/src/core/context-tree.ts @@ -83,6 +83,8 @@ export function computeOwnAndTotalUsage( if (isAssistantEntry(entry)) { branchAssistantIds.add(entry.id); addAssistantUsage(totalUsage, entry.message.usage); + } else if ((entry.type === "compaction" || entry.type === "branch_summary") && entry.usage) { + addAssistantUsage(totalUsage, entry.usage); } } const ownUsage = cloneUsage(totalUsage); diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index cd6e486deb..fc85697878 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -28,7 +28,14 @@ import { createCompactionSummaryMessage, createCustomMessage, } from "./messages.js"; -import { cloneUsage } from "./usage.js"; +import { + addAssistantUsage, + cloneUsage, + emptyUsage, + type SessionUsageSummary, + sessionUsageSummaryFrom, + subtractAssistantUsage, +} from "./usage.js"; export const CURRENT_SESSION_VERSION = 3; const SESSION_LIST_SEARCH_TEXT_MAX_CHARS = 64 * 1024; @@ -129,6 +136,7 @@ export interface CompactionEntry extends SessionEntryBase { details?: T; fromHook?: boolean; customInstructions?: string; + usage?: Usage; } export interface BranchSummaryEntry extends SessionEntryBase { @@ -137,6 +145,7 @@ export interface BranchSummaryEntry extends SessionEntryBase { summary: string; details?: T; fromHook?: boolean; + usage?: Usage; } export interface CustomEntry extends SessionEntryBase { @@ -255,6 +264,7 @@ export interface SessionInfo { firstMessage: string; allMessagesText: string; agentStatus?: AgentStatus; + usage?: SessionUsageSummary; } export type ReadonlySessionManager = Pick< @@ -953,6 +963,10 @@ async function scanSessionInfo(filePath: string, stats: Awaited(); + const attributedChildUsages: Usage[] = []; + const summarizationUsages: Usage[] = []; for await (const lineBuffer of readLinesAsBuffers(filePath)) { const line = lineBuffer.toString("utf8"); @@ -999,7 +1013,17 @@ async function scanSessionInfo(filePath: string, stats: Awaited = { type: "compaction", @@ -1459,6 +1498,7 @@ export class SessionManager { details, fromHook, customInstructions, + usage, }; this._appendEntry(entry); return entry.id; @@ -1836,7 +1876,13 @@ export class SessionManager { this.leafId = null; } - branchWithSummary(branchFromId: string | null, summary: string, details?: unknown, fromHook?: boolean): string { + branchWithSummary( + branchFromId: string | null, + summary: string, + details?: unknown, + fromHook?: boolean, + usage?: Usage, + ): string { if (branchFromId !== null && !this.byId.has(branchFromId)) { throw new Error(`Entry ${branchFromId} not found`); } @@ -1850,6 +1896,7 @@ export class SessionManager { summary, details, fromHook, + usage, }; this._appendEntry(entry); return entry.id; diff --git a/packages/coding-agent/src/core/usage.ts b/packages/coding-agent/src/core/usage.ts index 469edbc6d9..ae45dbc923 100644 --- a/packages/coding-agent/src/core/usage.ts +++ b/packages/coding-agent/src/core/usage.ts @@ -1,5 +1,19 @@ import type { Usage } from "@earendil-works/pi-ai"; +export interface SessionUsageSummary { + inputTokens: number; + outputTokens: number; + cost: number; +} + +export function sessionUsageSummaryFrom(usage: Usage): SessionUsageSummary | undefined { + const inputTokens = usage.input + usage.cacheRead + usage.cacheWrite; + if (inputTokens === 0 && usage.output === 0 && usage.cost.total === 0) { + return undefined; + } + return { inputTokens, outputTokens: usage.output, cost: usage.cost.total }; +} + export function emptyUsage(): Usage { return { input: 0, diff --git a/packages/coding-agent/src/modes/agent-connection/types.ts b/packages/coding-agent/src/modes/agent-connection/types.ts index 67408cd347..e1955207a2 100644 --- a/packages/coding-agent/src/modes/agent-connection/types.ts +++ b/packages/coding-agent/src/modes/agent-connection/types.ts @@ -27,6 +27,7 @@ import type { } from "../../core/session-action-store.js"; import type { DeleteSessionFileResult } from "../../core/session-file-actions.js"; import type { SessionStats } from "../../core/session-stats.js"; +import type { SessionUsageSummary } from "../../core/usage.js"; import type { SessionSummary } from "../daemon/daemon-session-list.js"; /** @@ -134,6 +135,7 @@ export interface AgentConnectionSavedSessionInfo { firstMessage: string; allMessagesText: string; agentStatus?: AgentConnectionAgentStatus; + usage?: SessionUsageSummary; } export type AgentConnectionSessionListProgress = (loaded: number, total: number) => void; diff --git a/packages/coding-agent/src/modes/agents-view/agents-view-mode.ts b/packages/coding-agent/src/modes/agents-view/agents-view-mode.ts index e97c8c9649..e36c972f43 100644 --- a/packages/coding-agent/src/modes/agents-view/agents-view-mode.ts +++ b/packages/coding-agent/src/modes/agents-view/agents-view-mode.ts @@ -44,6 +44,7 @@ import { listDaemonSavedSessions, renameDaemonSavedSession, } from "../daemon/saved-session-catalog.js"; +import { formatTokenCount } from "../interactive/agent-activity.js"; import { CustomEditor } from "../interactive/components/custom-editor.js"; import { keyText } from "../interactive/components/keybinding-hints.js"; import { BrandSplashHeader, InteractiveMode } from "../interactive/interactive-mode.js"; @@ -73,6 +74,7 @@ import { type AgentsViewSelectionKey, buildAgentsViewRows, buildUnifiedSessionIndex, + computeRecursiveCosts, createUnattachableChildOpenResult, filterUnifiedSessions, formatHeartbeatBadge, @@ -1285,6 +1287,7 @@ export class AgentsViewMode implements Component, Focusable { this.expandedSubagentParents, this.programShownParents, this.scopeKey, + computeRecursiveCosts(this.unifiedRecords, this.unifiedIndex), ); const index = selectedIdentity === undefined ? -1 : this.rows.findIndex((row) => row.identity === selectedIdentity); @@ -2171,6 +2174,7 @@ export class AgentsViewMode implements Component, Focusable { this.expandedSubagentParents, this.programShownParents, this.scopeKey, + computeRecursiveCosts(this.unifiedRecords, this.unifiedIndex), ); this.applyPendingAncestorExpansion(); this.restoreSelection(); @@ -2539,8 +2543,9 @@ export class AgentsViewMode implements Component, Focusable { const icon = this.formatRowIcon(row.section, rawIcon); const indent = " ".repeat(row.depth); const age = formatSessionDuration(row.summary); - const details = row.section === "inactive" ? `${row.summary.messageCount} · ${age}` : age; - const detailsWidth = row.section === "inactive" ? Math.max(10, visibleWidth(details)) : 10; + const usageText = formatRowUsage(row); + const details = usageText ? `${usageText} · ${age}` : age; + const detailsWidth = Math.max(10, visibleWidth(details)); const heartbeatBadge = !pendingDelete && !pendingKill ? formatHeartbeatBadge(row.heartbeat) : ""; const heartbeatPausedOnly = (row.heartbeat?.activeCount ?? 0) < 1; const heartbeatCell = heartbeatBadge ? theme.fg(heartbeatPausedOnly ? "dim" : "error", heartbeatBadge) : ""; @@ -2826,6 +2831,13 @@ function hasLiveWork(row: AgentsViewRow): boolean { return row.section === "running" || row.runningSubagentCount > 0 || row.summary.hasRunningRlmChildren === true; } +function formatRowUsage(row: AgentsViewRow): string { + const usage = row.summary.usage; + return `↑${formatTokenCount(usage?.inputTokens ?? 0)} ↓${formatTokenCount(usage?.outputTokens ?? 0)} · $${( + usage?.cost ?? 0 + ).toFixed(2)} ($${row.recursiveCost.toFixed(2)} w/ subagents)`; +} + // Explicit session names read bold so they stand out from fallback titles // (first prompt, cwd, ids); the "(no messages)" placeholder reads italic. function styleRowTitle(row: AgentsViewRow): string { diff --git a/packages/coding-agent/src/modes/agents-view/agents-view-state.ts b/packages/coding-agent/src/modes/agents-view/agents-view-state.ts index 2e2b3c03d4..5ad1db9577 100644 --- a/packages/coding-agent/src/modes/agents-view/agents-view-state.ts +++ b/packages/coding-agent/src/modes/agents-view/agents-view-state.ts @@ -76,6 +76,7 @@ export interface AgentsViewRow { depth: number; selectable: boolean; runningSubagentCount: number; + recursiveCost: number; /** Unique selection identity for this row. */ identity: string; /** Identity of the agent row this row is nested under. */ @@ -248,6 +249,7 @@ export function summaryForUnifiedRecord(record: UnifiedSessionRecord): SessionSu ...record.daemon, sessionName: record.daemon.sessionName ?? saved.name, firstMessage: record.daemon.firstMessage ?? saved.firstMessage, + usage: record.daemon.usage ?? saved.usage, sessionFile: record.daemon.sessionFile ?? canonicalSessionPath(saved.path), parentSessionPath: record.daemon.parentSessionPath ?? saved.parentSessionPath, rlmDepth: record.daemon.rlmDepth ?? saved.rlmDepth, @@ -281,6 +283,7 @@ export function summaryForUnifiedRecord(record: UnifiedSessionRecord): SessionSu firstMessage: saved.firstMessage, summary: saved.agentStatus?.summary, taskState: saved.agentStatus?.taskState, + usage: saved.usage, }; } @@ -418,6 +421,32 @@ export interface UnifiedSessionIndex { childrenByParent: Map; } +// Rolls costs over the UNFILTERED hierarchy: filters must never change a row's total. +export function computeRecursiveCosts( + records: readonly UnifiedSessionRecord[], + index: UnifiedSessionIndex = buildUnifiedSessionIndex(records), +): ReadonlyMap { + const order = records.filter((record) => { + const parent = findParentRecord(record, index.byKey); + return !parent || parent === record; + }); + for (let position = 0; position < order.length; position++) { + for (const child of index.childrenByParent.get(order[position]!) ?? []) { + order.push(child); + } + } + const costs = new Map(); + for (let position = order.length - 1; position >= 0; position--) { + const record = order[position]!; + let total = record.daemon?.usage?.cost ?? record.saved?.usage?.cost ?? 0; + for (const child of index.childrenByParent.get(record) ?? []) { + total += costs.get(child) ?? 0; + } + costs.set(record, total); + } + return costs; +} + export function buildUnifiedSessionIndex(records: readonly UnifiedSessionRecord[]): UnifiedSessionIndex { const byKey = new Map(); for (const record of records) { @@ -668,6 +697,7 @@ export function buildAgentsViewRows( expandedSubagentParents: ReadonlySet = new Set(), programShownParents: ReadonlySet = new Set(), scope?: AgentsViewScopeKey, + recursiveCosts?: ReadonlyMap, ): AgentsViewRow[] { const inputs = summariesOrRecords.map((input) => isUnifiedSessionRecord(input) ? { summary: summaryForUnifiedRecord(input), record: input } : { summary: input }, @@ -695,6 +725,7 @@ export function buildAgentsViewRows( depth: 0, selectable: true, runningSubagentCount: 0, + recursiveCost: summary.usage?.cost ?? 0, identity: record?.identity ?? getAgentsViewSummaryIdentity(summary), ...(record ? { record, heartbeat: record.heartbeat } : {}), }), @@ -729,10 +760,14 @@ export function buildAgentsViewRows( for (let index = tallyOrder.length - 1; index >= 0; index--) { const row = tallyOrder[index]!; let count = 0; + let descendantsCost = 0; for (const child of childrenByParent.get(row) ?? []) { count += (child.section === "running" ? 1 : 0) + child.runningSubagentCount; + descendantsCost += child.recursiveCost; } row.runningSubagentCount = count; + row.recursiveCost = + (row.record ? recursiveCosts?.get(row.record) : undefined) ?? (row.summary.usage?.cost ?? 0) + descendantsCost; } const roots = baseRows.filter((row) => !nestedRows.has(row)); @@ -810,6 +845,7 @@ function createSubagentSummaryRow( depth, selectable: true, runningSubagentCount: running, + recursiveCost: 0, identity: `subagents:${parent.identity}`, parentIdentity: parent.identity, hasSpawnCode, @@ -864,6 +900,7 @@ function buildSpawnCodeRows( // Code rows are read-only context; selection skips over them. selectable: false, runningSubagentCount: 0, + recursiveCost: 0, identity: `code:${parent.identity}:${groupIndex}:${lineIndex}`, parentIdentity: parent.identity, code, diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 28e6e1108f..a052ee863f 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -214,6 +214,7 @@ import { RlmSpawnLedger, readLegacyRlmSubagentRegistry as readLegacyRlmSubagentRegistryFile, tombstoneSavedSessionDelete, + withPassiveRlmDescendantInfos, } from "./rlm-ledger.js"; import { readRlmSubagentDisplayEntry, @@ -981,6 +982,16 @@ export class AgentDaemon { return this.rlmSpawnLedgerInstance; } + // Ledgers are per sessions-dir family: a catalog request for another dir must read that dir's ledger. + private rlmSpawnLedgerFor(sessionDir: string | undefined): RlmSpawnLedger { + if (sessionDir === undefined || resolve(sessionDir) === resolve(this.rlmLedgerSessionsDir())) { + return this.rlmSpawnLedger(); + } + return new RlmSpawnLedger(this.agentDir, sessionDir, createRlmLedgerRegistrySeedSource(), (message) => + this.log(message), + ); + } + private async appendRlmLedgerRenameForState(state: ActiveSessionState, name: string): Promise { const childId = state.runtime.metadata.rlmChildId; const child = state.runtime.session.sessionFile; @@ -3875,8 +3886,13 @@ export class AgentDaemon { command.scope === "current" ? await SessionManager.list(cwd, sessionDir, callbacks) : await SessionManager.listAll(callbacks, sessionDir); + const sessions = await withPassiveRlmDescendantInfos(savedSessions, this.rlmSpawnLedgerFor(sessionDir), { + ...(command.scope === "current" ? { cwd } : {}), + ...(callbacks ? { onSession: callbacks.onSession } : {}), + log: (message) => this.log(message), + }); return success(command.id, "list_saved_sessions", { - sessions: savedSessions.map(serializeSavedSessionInfo), + sessions: sessions.map(serializeSavedSessionInfo), }); } diff --git a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts index 707009989f..1511eaa6bc 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts @@ -22,6 +22,7 @@ import type { CustomMessage } from "../../core/messages.js"; import type { QueuedMessageLane, QueuedMessageMutation } from "../../core/session-action-store.js"; import type { SessionCwdIssue } from "../../core/session-cwd.js"; import type { DeleteSessionFileResult } from "../../core/session-file-actions.js"; +import type { SessionUsageSummary } from "../../core/usage.js"; import type { AgentConnectionAgentStatus, AgentConnectionHeartbeat, @@ -70,8 +71,9 @@ export const DAEMON_COMMAND_ENVELOPE_MIN_PROTOCOL_VERSION = 7; // Revision 23 lets workers query the supervisor agent roster on demand. // Revision 24 adds the capability-gated agent-roster subscription and push. // Revision 25 adds capability-gated direct worker peer transport discovery. -export const DAEMON_SCHEMA_REVISION = 25; -export const DAEMON_SCHEMA_ID = "protocol-7-schema-25-585ef1102921"; +// Revision 26 publishes own-session usage totals on session summary and saved-session rows. +export const DAEMON_SCHEMA_REVISION = 26; +export const DAEMON_SCHEMA_ID = "protocol-7-schema-26-962b8b4c5e35"; export type DaemonProtocolName = typeof DAEMON_PROTOCOL_NAME; export type DaemonProtocolVersion = number; @@ -1074,6 +1076,7 @@ export interface DaemonSavedSessionInfo { firstMessage: string; allMessagesText: string; agentStatus?: AgentConnectionAgentStatus; + usage?: SessionUsageSummary; } export type DaemonDeleteSavedSessionResult = DeleteSessionFileResult; diff --git a/packages/coding-agent/src/modes/daemon/daemon-session-list.ts b/packages/coding-agent/src/modes/daemon/daemon-session-list.ts index 6dd9fc4953..21e0794e32 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-session-list.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-session-list.ts @@ -7,6 +7,7 @@ import type { AgentSessionRuntimeDiagnostic } from "../../core/agent-session-ser import { type AgentCronJob, isHeartbeatCronJob } from "../../core/cron-jobs.js"; import type { SessionActionSnapshot } from "../../core/session-action-store.js"; import type { AgentTaskState, SessionInfo } from "../../core/session-manager.js"; +import type { SessionUsageSummary } from "../../core/usage.js"; import type { AgentConnectionRlmChildAgentSnapshot } from "../agent-connection/types.js"; import type { ActiveSessionState } from "./active-session-state.js"; @@ -55,6 +56,7 @@ export interface SessionSummary { isCompacting: boolean; isBashRunning?: boolean; hasRunningRlmChildren?: boolean; + usage?: SessionUsageSummary; /** True while the agent is streaming with tool calls pending; drives the "running tools" label. */ isRunningTools?: boolean; attachedClients: number; @@ -258,6 +260,7 @@ export function summaryForActiveSession( isCompacting: session.isCompacting, isBashRunning: session.isBashRunning, hasRunningRlmChildren: session.hasRunningRlmChildren(), + usage: session.getOwnUsageSummary?.(), isRunningTools: session.isStreaming && session.state.pendingToolCalls.size > 0, attachedClients: activeSession.clients.size, ...(directAttachedClients > 0 ? { directAttachedClients } : {}), @@ -346,6 +349,7 @@ export function summaryForInactiveSession( firstMessage: session.firstMessage, parentSessionPath: session.parentSessionPath, rlmDepth: session.rlmDepth, + usage: session.usage, // Carry the persisted recap/verdict so an off-daemon session keeps its // agents-view bucket (e.g. Completed) instead of defaulting to Needs Input. // Gate on message-count currency like isSummaryCurrent does for resident diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 90194ec478..55ade4be1e 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -166,6 +166,7 @@ import { type RlmLedgerEdge, RlmSpawnLedger, tombstoneSavedSessionDelete, + withPassiveRlmDescendantInfos, } from "./rlm-ledger.js"; import { serializeSavedSessionInfo } from "./saved-session-info.js"; import { SNAPSHOT_TARGET_CHUNK_BYTES, SnapshotTranscriptCache } from "./snapshot-transcript-cache.js"; @@ -2831,7 +2832,12 @@ export class DaemonSupervisor { } : undefined; const saved = await this.catalog.list(command.scope === "current" ? cwd : undefined, sessionDir, callbacks); - return success(command.id, "list_saved_sessions", { sessions: saved.map(serializeSavedSessionInfo) }); + const sessions = await withPassiveRlmDescendantInfos(saved, this.rlmSpawnLedgerFor(sessionDir), { + ...(command.scope === "current" ? { cwd } : {}), + ...(callbacks ? { onSession: callbacks.onSession } : {}), + log: (message) => this.log(message), + }); + return success(command.id, "list_saved_sessions", { sessions: sessions.map(serializeSavedSessionInfo) }); } private async createOrReuseWorker(clientId: string, command: DaemonCreateCommand): Promise { @@ -4553,6 +4559,21 @@ export class DaemonSupervisor { return this.rlmSpawnLedgerInstance; } + // Ledgers are per sessions-dir family: a catalog request for another dir must read that dir's ledger. + private rlmSpawnLedgerFor(sessionDir: string | undefined): RlmSpawnLedger { + const agentDir = this.defaultSessionConfig.agentDir; + const defaultDir = this.defaultSessionConfig.sessionDir ?? (agentDir ? getSessionsDir(agentDir) : undefined); + if (sessionDir === undefined || (defaultDir !== undefined && resolve(sessionDir) === resolve(defaultDir))) { + return this.rlmSpawnLedger(); + } + if (!agentDir) { + throw new Error("Daemon supervisor config is missing agentDir"); + } + return new RlmSpawnLedger(agentDir, sessionDir, createRlmLedgerRegistrySeedSource(), (message) => + this.log(message), + ); + } + /** * Ledger-backed same-parent rows for name reservation and admission. Rows * carry ledger topology plus best-effort display fields; consumers here diff --git a/packages/coding-agent/src/modes/daemon/rlm-ledger.ts b/packages/coding-agent/src/modes/daemon/rlm-ledger.ts index 4fec964f3c..dd5288b0e0 100644 --- a/packages/coding-agent/src/modes/daemon/rlm-ledger.ts +++ b/packages/coding-agent/src/modes/daemon/rlm-ledger.ts @@ -757,6 +757,44 @@ export class RlmSpawnLedger { } } +// The catalog scan never visits session-artifacts, where RLM children persist: +// without this merge a passivated descendant's row (and its spend) survives only +// as long as some resident roster remembers it. +export async function withPassiveRlmDescendantInfos( + savedSessions: SessionInfo[], + ledger: RlmSpawnLedger, + options: { cwd?: string; onSession?: (info: SessionInfo) => void; log?: (message: string) => void } = {}, +): Promise { + const sessions = [...savedSessions]; + const seen = new Set(savedSessions.map((info) => canonicalSessionPath(info.path))); + let edges: RlmLedgerEdge[]; + try { + edges = await ledger.liveEdges(); + } catch (error) { + // A broken ledger must not take the whole catalog down with it. + options.log?.(`Could not merge passive RLM descendants: ${String(error)}`); + return sessions; + } + for (const edge of edges) { + const childPath = canonicalSessionPath(edge.child); + if (seen.has(childPath)) continue; + seen.add(childPath); + const info = await readSessionInfo(childPath); + if (!info) continue; + if (options.cwd !== undefined && (!info.cwd || resolve(info.cwd) !== resolve(options.cwd))) continue; + // The ledger edge is the authoritative topology (family() semantics); a fork + // can leave the transcript header pointing at a dead ancestor path. + const merged: SessionInfo = { + ...info, + parentSessionPath: edge.parent, + rlmDepth: edge.depth, + }; + sessions.push(merged); + options.onSession?.(merged); + } + return sessions; +} + // Shared user-delete policy: only a readable no-parent transcript is positively top-level; children and // unknown targets tombstone via the ledger BEFORE the file delete (a tombstoned-but-undeleted file is // the accepted orphan of a failed delete). diff --git a/packages/coding-agent/src/modes/daemon/saved-session-info.ts b/packages/coding-agent/src/modes/daemon/saved-session-info.ts index 0eaa8d0727..dfae72d707 100644 --- a/packages/coding-agent/src/modes/daemon/saved-session-info.ts +++ b/packages/coding-agent/src/modes/daemon/saved-session-info.ts @@ -17,6 +17,7 @@ export function serializeSavedSessionInfo(session: SessionInfo): DaemonSavedSess firstMessage: session.firstMessage, allMessagesText: session.allMessagesText, agentStatus: session.agentStatus, + usage: session.usage, }; } @@ -35,5 +36,6 @@ export function deserializeSavedSessionInfo(session: DaemonSavedSessionInfo): Ag firstMessage: session.firstMessage, allMessagesText: session.allMessagesText, agentStatus: session.agentStatus, + usage: session.usage, }; } diff --git a/packages/coding-agent/src/modes/index.ts b/packages/coding-agent/src/modes/index.ts index 89ece8f474..67327ca102 100644 --- a/packages/coding-agent/src/modes/index.ts +++ b/packages/coding-agent/src/modes/index.ts @@ -41,6 +41,7 @@ export { buildAgentsViewRows, buildUnifiedSessionIndex, classifyAgentsViewSession, + computeRecursiveCosts, createUnattachableChildOpenResult, filterUnifiedSessions, formatHeartbeatBadge, diff --git a/packages/coding-agent/test/agents-view-mode.test.ts b/packages/coding-agent/test/agents-view-mode.test.ts index 331745822f..5c947b67c7 100644 --- a/packages/coding-agent/test/agents-view-mode.test.ts +++ b/packages/coding-agent/test/agents-view-mode.test.ts @@ -717,6 +717,52 @@ describe("AgentsViewMode", () => { } }); + it("renders the unconditional usage cell and drops the message count", () => { + const parent = summary({ + id: "spender", + activeSessionId: "spender", + sessionId: "spender-session", + usage: { inputTokens: 12437, outputTokens: 1234, cost: 0.42 }, + }); + const child = summary({ + id: "spender-child", + activeSessionId: "spender-child", + sessionId: "spender-child-session", + sessionFile: "/tmp/spender-child.jsonl", + runtimeKind: "subagent", + parentActiveSessionId: "spender", + usage: { inputTokens: 500, outputTokens: 50, cost: 0.68 }, + }); + const inactive = summary({ + id: "saved-only", + activeSessionId: undefined, + sessionId: "saved-only-session", + sessionFile: "/tmp/saved-only.jsonl", + rosterStatus: "inactive", + messageCount: 7, + }); + const view = new AgentsViewMode({ config: {}, uiServices: createUiServices() }, {}); + + try { + const collapsed = buildAgentsViewRows([parent, child, inactive]); + const rows = buildAgentsViewRows([parent, child, inactive], new Set(collapsed.map((row) => row.identity))); + Reflect.set(view, "rows", rows); + const line = (row: AgentsViewRow | undefined) => stripAnsi(invoke("renderRow", view, row, 200) as string); + const byId = (sessionId: string, kind?: string) => + rows.find((row) => row.summary.sessionId === sessionId && (!kind || row.kind === kind)); + + expect(line(byId("spender-session"))).toContain("↑12k ↓1.2k · $0.42 ($1.10 w/ subagents)"); + expect(line(byId("spender-child-session", "subagent"))).toContain("↑500 ↓50 · $0.68 ($0.68 w/ subagents)"); + const inactiveLine = line(byId("saved-only-session")); + expect(inactiveLine).toContain("↑0 ↓0 · $0.00 ($0.00 w/ subagents)"); + expect(inactiveLine).not.toContain("7 ·"); + const bare = { ...byId("spender-session")!, summary: { ...parent, usage: undefined } }; + expect(line(bare)).toContain("↑0 ↓0 · $0.00 ($1.10 w/ subagents)"); + } finally { + stopThemeWatcher(); + } + }); + it("renders a collapsed group's busy-subagent badge legibly instead of dimmed", () => { const parent = summary({ id: "parent", activeSessionId: "parent", sessionId: "parent-session" }); const busyChild = summary({ diff --git a/packages/coding-agent/test/agents-view-state.test.ts b/packages/coding-agent/test/agents-view-state.test.ts index b2f89d33c8..6807081fde 100644 --- a/packages/coding-agent/test/agents-view-state.test.ts +++ b/packages/coding-agent/test/agents-view-state.test.ts @@ -27,6 +27,7 @@ import { buildAgentsViewRows, buildUnifiedSessionIndex, classifyAgentsViewSession, + computeRecursiveCosts, createUnattachableChildOpenResult, filterUnifiedSessions, formatHeartbeatBadge, @@ -499,6 +500,81 @@ describe("agents view state", () => { expect(expanded.find((row) => row.title === "Child")?.runningSubagentCount).toBe(0); }); + test("keeps the recursive total complete when search filters out a descendant", () => { + const parent = makeSummary({ + id: "parent-active", + activeSessionId: "parent-active", + sessionId: "parent-session", + sessionName: "Searchable parent", + usage: { inputTokens: 100, outputTokens: 10, cost: 0.42 }, + }); + const child = makeSummary({ + id: "child-active", + activeSessionId: "child-active", + sessionId: "child-session", + sessionName: "unrelated worker", + runtimeKind: "subagent", + parentActiveSessionId: "parent-active", + usage: { inputTokens: 50, outputTokens: 5, cost: 0.68 }, + }); + const grandchild = makeSummary({ + id: "grandchild-active", + activeSessionId: "grandchild-active", + sessionId: "grandchild-session", + sessionName: "unrelated nested worker", + runtimeKind: "subagent", + parentActiveSessionId: "child-active", + usage: { inputTokens: 20, outputTokens: 2, cost: 0.18 }, + }); + const records = reconcileUnifiedSessions([parent, child, grandchild], []); + const costs = computeRecursiveCosts(records); + const filtered = filterUnifiedSessions(records, (text) => text.includes("Searchable")); + + expect(filtered).toHaveLength(1); + const rows = buildAgentsViewRows(filtered, new Set(), new Set(), undefined, costs); + expect(rows[0]?.summary.usage?.cost).toBe(0.42); + expect(rows[0]?.recursiveCost).toBeCloseTo(1.28); + }); + + test("keeps a parent's recursive total when a passivated child survives only as a catalog row", () => { + const parent = makeSummary({ + id: "parent-active", + activeSessionId: "parent-active", + sessionId: "parent-session", + sessionFile: "/tmp/project/parent.jsonl", + usage: { inputTokens: 100, outputTokens: 10, cost: 0.42 }, + }); + const liveChild = makeSummary({ + id: "child-active", + activeSessionId: "child-active", + sessionId: "child-session", + sessionFile: "/tmp/project/child.jsonl", + runtimeKind: "subagent", + parentActiveSessionId: "parent-active", + usage: { inputTokens: 50, outputTokens: 5, cost: 0.68 }, + }); + const before = reconcileUnifiedSessions([parent, liveChild], []); + const beforeTotal = computeRecursiveCosts(before).get(before[0]!); + + // After a restart the child exists only as a saved-catalog row. + const after = reconcileUnifiedSessions( + [parent], + [ + makeSessionInfo({ + path: "/tmp/project/child.jsonl", + id: "child-session", + parentSessionPath: "/tmp/project/parent.jsonl", + rlmDepth: 1, + usage: { inputTokens: 50, outputTokens: 5, cost: 0.68 }, + }), + ], + ); + const afterTotal = computeRecursiveCosts(after).get(after[0]!); + + expect(beforeTotal).toBeCloseTo(1.1); + expect(afterTotal).toBe(beforeTotal); + }); + test("tallies a very deep child chain without overflowing the stack", () => { const summaries = [ makeSummary({ @@ -522,7 +598,12 @@ describe("agents view state", () => { runtimeKind: "subagent", parentActiveSessionId: level === 1 ? "chain-root" : `chain-${level - 1}`, ...(level === depth - ? { activity: "working" as const, isSessionActive: true, isStreaming: true } + ? { + activity: "working" as const, + isSessionActive: true, + isStreaming: true, + usage: { inputTokens: 100, outputTokens: 10, cost: 0.5 }, + } : { activity: "idle" as const, taskState: "completed" as const }), }), ); @@ -530,6 +611,7 @@ describe("agents view state", () => { const rows = buildAgentsViewRows(summaries); expect(rows[0]).toMatchObject({ kind: "agent", section: "idle", runningSubagentCount: 1 }); + expect(rows[0]?.recursiveCost).toBeCloseTo(0.5); }); test("ranks idle rows with busy descendants above plain idle rows", () => { @@ -1719,6 +1801,13 @@ describe("agents view state", () => { [root, registryChild], [ makeSessionInfo({ path: rootPath, id: "root-session", rlmDepth: 0 }), + // The catalog also lists the resident child's file: it must merge, not duplicate. + makeSessionInfo({ + path: "/tmp/project/registry-child.jsonl", + id: "registry-child", + parentSessionPath: rootPath, + rlmDepth: 1, + }), makeSessionInfo({ path: "/tmp/project/saved-child.jsonl", id: "saved-child", @@ -1815,6 +1904,7 @@ function makeSessionInfo(overrides: Partial & { path: string; id: s firstMessage: overrides.firstMessage ?? "hello", allMessagesText: overrides.allMessagesText ?? "hello", agentStatus: overrides.agentStatus, + usage: overrides.usage, }; } diff --git a/packages/coding-agent/test/daemon-session-list.test.ts b/packages/coding-agent/test/daemon-session-list.test.ts index 75f2a0b50f..61a9a2cd60 100644 --- a/packages/coding-agent/test/daemon-session-list.test.ts +++ b/packages/coding-agent/test/daemon-session-list.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import type { RlmChildAgentSnapshot } from "../src/core/agent-session.js"; import type { AgentCronJob } from "../src/core/cron-jobs.js"; import type { SessionInfo } from "../src/core/session-manager.js"; +import type { SessionUsageSummary } from "../src/core/usage.js"; import type { ActiveSessionState, DaemonSocketClient } from "../src/modes/daemon/active-session-state.js"; import { passivatedWorkerRosterEntry, workerRosterEntryFromSummary } from "../src/modes/daemon/agent-roster.js"; import { @@ -127,6 +128,16 @@ describe("buildSessionList", () => { expect(summary.lastActivityAt).toBe(new Date(validTimestamp).toISOString()); }); + it("publishes own-session usage on active and saved rows", () => { + const usage: SessionUsageSummary = { inputTokens: 12437, outputTokens: 1234, cost: 0.42 }; + const [active, saved] = buildSessionList( + [makeState({ activeSessionId: "spender", usage })], + [makeSessionInfo({ id: "saved-spender", path: "/tmp/saved-spender.jsonl", usage })], + ); + expect(active?.usage).toEqual(usage); + expect(saved?.usage).toEqual(usage); + }); + it("keeps background subagents on the wire while the settled parent goes idle", () => { const oneMessage = [{ role: "user", content: "hi" }] as unknown as AgentMessage[]; const entries = buildSessionList( @@ -623,6 +634,7 @@ interface StateOptions { messages?: AgentMessage[]; hasUserContent?: boolean; summaryState?: ActiveSessionState["summaryState"]; + usage?: SessionUsageSummary; hasRunningRlmChildren?: boolean; hasAcceptedPromptInFlight?: boolean; unfinishedActionCount?: number; @@ -674,6 +686,7 @@ function makeState(options: StateOptions): ActiveSessionState { }, messages: options.messages ?? ([] as AgentMessage[]), getRlmChildSnapshots: () => options.childSnapshots ?? [], + getOwnUsageSummary: () => options.usage, hasRunningRlmChildren: () => options.hasRunningRlmChildren ?? false, hasAcceptedPromptInFlight: options.hasAcceptedPromptInFlight ?? false, unfinishedActionCount: options.unfinishedActionCount ?? (options.hasAcceptedPromptInFlight ? 1 : 0), @@ -712,6 +725,7 @@ function makeSessionInfo(overrides: Pick & Partial { + it("serves passivated descendants in the saved catalog after a supervisor restart", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "prime-rlm-ledger-catalog-supervisor-")); + try { + const { sessionsDir, parent, parentFile } = makeRoots(tempDir); + const parentArtifactDir = parent.getSessionArtifactDir(); + if (!parentArtifactDir) throw new Error("Missing parent artifact directory"); + // The transcript header points at a forked-away ancestor; the ledger edge is the truth. + const staleParent = join(tempDir, "forked-away-parent.jsonl"); + const child = makeChildSession(tempDir, join(parentArtifactDir, "sub-11111111"), staleParent, 2, "worker"); + child.manager.appendMessage({ role: "user", content: "shard", timestamp: 1 }); + child.manager.flushNow(); + const deleted = makeChildSession(tempDir, join(parentArtifactDir, "sub-22222222"), parentFile, 1, "gone"); + const parentInfo = await sessionManagerModule.readSessionInfo(parentFile); + if (!parentInfo) throw new Error("Missing parent session info"); + const supervisor = new DaemonSupervisor(join(tempDir, "daemon.sock"), { + defaultSessionConfig: { agentDir: tempDir, cwd: tempDir, sessionDir: sessionsDir }, + descriptorDir: join(tempDir, "workers"), + }) as unknown as SupervisorLedgerInternals; + Object.assign(supervisor.catalog, { list: vi.fn(async () => [parentInfo]) }); + const ledger = supervisor.rlmSpawnLedger(); + await ledger.appendSpawn({ + childId: "sub-11111111", + parent: parentFile, + child: child.file, + depth: 1, + name: "worker", + }); + await ledger.appendSpawn({ + childId: "sub-22222222", + parent: parentFile, + child: deleted.file, + depth: 1, + name: "gone", + }); + await ledger.appendDelete({ childId: "sub-22222222", child: deleted.file, reason: "user" }); + + const response = await supervisor.handleCommand( + {}, + { type: "list_saved_sessions", cwd: tempDir, sessionDir: sessionsDir, scope: "all" }, + ); + if (!response?.success) throw new Error("list_saved_sessions failed"); + const sessions = ( + response.data as { + sessions: Array<{ id: string; parentSessionPath?: string; rlmDepth?: number; messageCount: number }>; + } + ).sessions; + expect(sessions.map(({ id }) => id)).toEqual([parentInfo.id, child.manager.getSessionId()]); + expect(sessions[1]).toMatchObject({ + parentSessionPath: canonicalSessionPath(parentFile), + rlmDepth: 1, + messageCount: 1, + }); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("merges each requested dir's own passivated descendants and survives a broken ledger", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "prime-rlm-ledger-catalog-daemon-")); + try { + const { internals, sessionsDir } = makeDaemonFixture(tempDir); + const { parent, parentFile } = makeRoots(tempDir); + const parentArtifactDir = parent.getSessionArtifactDir(); + if (!parentArtifactDir) throw new Error("Missing parent artifact directory"); + const child = makeChildSession(tempDir, join(parentArtifactDir, "sub-33333333"), parentFile, 1, "worker"); + await internals.rlmSpawnLedger().appendSpawn({ + childId: "sub-33333333", + parent: parentFile, + child: child.file, + depth: 1, + name: "worker", + }); + // A second sessions-dir family with its own ledger: listings must not cross. + const otherDir = join(tempDir, "other-sessions"); + const otherParent = SessionManager.create(tempDir, otherDir); + otherParent.newSession(); + otherParent.appendSessionInfo("other-parent"); + otherParent.flushNow(); + const otherParentFile = otherParent.getSessionFile(); + if (!otherParentFile) throw new Error("Missing other parent session file"); + const otherChild = makeChildSession( + tempDir, + join(tempDir, "other-artifacts", "sub-44444444"), + otherParentFile, + 1, + "other-worker", + ); + await new RlmSpawnLedger(tempDir, otherDir).appendSpawn({ + childId: "sub-44444444", + parent: otherParentFile, + child: otherChild.file, + depth: 1, + name: "other-worker", + }); + + const handle = internals as unknown as { + handleCommand(client: object, command: Record): Promise; + }; + const list = async (dir: string) => { + const response = (await handle.handleCommand( + {}, + { type: "list_saved_sessions", cwd: tempDir, sessionDir: dir, scope: "all" }, + )) as { success: boolean; data: { sessions: Array<{ id: string }> } }; + expect(response.success).toBe(true); + return response.data.sessions.map(({ id }) => id); + }; + const defaultIds = await list(sessionsDir); + expect(defaultIds).toContain(child.manager.getSessionId()); + expect(defaultIds).not.toContain(otherChild.manager.getSessionId()); + const otherIds = await list(otherDir); + expect(otherIds).toContain(otherChild.manager.getSessionId()); + expect(otherIds).not.toContain(child.manager.getSessionId()); + + // A directory squatting where the other family's ledger file should be: every read throws. + rmSync(rlmLedgerPath(tempDir, otherDir), { force: true }); + mkdirSync(rlmLedgerPath(tempDir, otherDir), { recursive: true }); + expect(await list(otherDir)).toEqual([otherParent.getSessionId()]); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); + describe("rlm spawn ledger supervisor wiring", () => { it("hydrates a ledger-seeded child's cwd before publishing the roster", async () => { const tempDir = mkdtempSync(join(tmpdir(), "prime-rlm-ledger-supervisor-cwd-")); diff --git a/packages/coding-agent/test/session-manager/file-operations.test.ts b/packages/coding-agent/test/session-manager/file-operations.test.ts index cfd1217034..2b91908eee 100644 --- a/packages/coding-agent/test/session-manager/file-operations.test.ts +++ b/packages/coding-agent/test/session-manager/file-operations.test.ts @@ -2,6 +2,7 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { computeOwnAndTotalUsage } from "../../src/core/context-tree.js"; import { findMostRecentSession, loadEntriesFromFile, @@ -10,6 +11,7 @@ import { resolveSessionRlmDepth, SessionManager, } from "../../src/core/session-manager.js"; +import { sessionUsageSummaryFrom } from "../../src/core/usage.js"; describe("loadEntriesFromFile", () => { let tempDir: string; @@ -587,3 +589,66 @@ describe("SessionManager.setSessionFile with corrupted files", () => { expect(sm2.getHeader()?.type).toBe("session"); }); }); + +describe("session info usage totals", () => { + it("scan and resident computation agree on whole-file own spend, forks and attributions included", async () => { + const tempDir = join(tmpdir(), `session-usage-test-${Date.now()}`); + mkdirSync(tempDir, { recursive: true }); + try { + const usage = (input: number, output: number, cost: number, cacheRead = 10, cacheWrite = 5) => ({ + input, + output, + cacheRead, + cacheWrite, + totalTokens: input + output, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: cost }, + }); + const msg = (id: string, parentId: string | null, role: string, u?: unknown) => + ({ type: "message", id, parentId, message: { role, content: "x", timestamp: 1, usage: u } }) as const; + const file = join(tempDir, "usage.jsonl"); + const lines = [ + { type: "session", version: 3, id: "s1", timestamp: "2026-01-01T00:00:00Z", cwd: "/tmp" }, + msg("m1", null, "user"), + msg("m2", "m1", "assistant", usage(1000, 200, 0.5)), + // On-disk original usage; the loader folds the aggregate below onto it in memory. + msg("m3", "m1", "assistant", usage(2000, 300, 1.0)), + { + type: "child_usage_attributed", + id: "a1", + parentId: "m3", + targetId: "m3", + childUsage: usage(500, 100, 0.4), + aggregateUsage: usage(2500, 400, 1.4, 20, 10), + }, + { + type: "compaction", + id: "c1", + parentId: "m3", + summary: "compacted", + firstKeptEntryId: "m3", + tokensBefore: 5000, + usage: usage(100, 20, 0.05), + }, + { + type: "branch_summary", + id: "b1", + parentId: "c1", + fromId: "m1", + summary: "left", + usage: usage(60, 8, 0.02), + }, + ]; + writeFileSync(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`); + + const entries = SessionManager.open(file).getEntries(); + const resident = sessionUsageSummaryFrom(computeOwnAndTotalUsage(entries, entries).ownUsage); + + const scanned = (await readSessionInfo(file))?.usage; + expect(scanned).toMatchObject({ inputTokens: 3220, outputTokens: 528 }); + expect(scanned?.cost).toBeCloseTo(1.57); + expect(resident).toEqual(scanned); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/coding-agent/test/suite/agent-session-compaction.test.ts b/packages/coding-agent/test/suite/agent-session-compaction.test.ts index f19ce5b304..53d40829b5 100644 --- a/packages/coding-agent/test/suite/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/suite/agent-session-compaction.test.ts @@ -1,6 +1,12 @@ import { appendFileSync } from "node:fs"; import { AgentContinueError, type AgentMessage, type ShouldStopAfterTurnContext } from "@earendil-works/pi-agent-core"; -import { type AssistantMessage, fauxAssistantMessage, type Model, type ToolResultMessage } from "@earendil-works/pi-ai"; +import { + type AssistantMessage, + fauxAssistantMessage, + type Model, + type ToolResultMessage, + type Usage, +} from "@earendil-works/pi-ai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { SessionManager } from "../../src/core/session-manager.js"; import { createHarness, getMessageText, type Harness } from "./harness.js"; @@ -144,6 +150,7 @@ describe("AgentSession compaction characterization", () => { ]); await harness.session.prompt("one"); await harness.session.prompt("two"); + const usageBeforeCompaction = harness.session.getOwnUsageSummary(); const result = await harness.session.compact(); const entry = harness.sessionManager.getEntries().find((candidate) => candidate.type === "compaction"); @@ -158,6 +165,16 @@ describe("AgentSession compaction characterization", () => { tokensBefore: result.tokensBefore, fromHook: false, }); + const compactionUsage = (entry as { usage: Usage }).usage; + expect(compactionUsage.input).toBeGreaterThan(0); + expect(compactionUsage.output).toBeGreaterThan(0); + // Own spend grows by exactly what the compaction entry recorded. + const ownUsage = harness.session.getOwnUsageSummary(); + expect((ownUsage?.inputTokens ?? 0) - (usageBeforeCompaction?.inputTokens ?? 0)).toBe( + compactionUsage.input + compactionUsage.cacheRead + compactionUsage.cacheWrite, + ); + expect((ownUsage?.outputTokens ?? 0) - (usageBeforeCompaction?.outputTokens ?? 0)).toBe(compactionUsage.output); + expect((ownUsage?.cost ?? 0) - (usageBeforeCompaction?.cost ?? 0)).toBeCloseTo(compactionUsage.cost.total); expect(harness.session.messages[0]).toMatchObject({ role: "compactionSummary", summary: expect.stringContaining("model-generated summary"), diff --git a/packages/coding-agent/test/suite/regressions/502-unified-session-view.test.ts b/packages/coding-agent/test/suite/regressions/502-unified-session-view.test.ts index c0b6a4b261..0de5ced79d 100644 --- a/packages/coding-agent/test/suite/regressions/502-unified-session-view.test.ts +++ b/packages/coding-agent/test/suite/regressions/502-unified-session-view.test.ts @@ -409,7 +409,7 @@ describe("#502 unified session view regressions", () => { expect(filtered.map((record) => record.identity)).toEqual(["match"]); }); - test("inactive rows give message count and age their full responsive cell", () => { + test("inactive rows give usage and age their full responsive cell", () => { const inactive = { kind: "agent" as const, section: "inactive" as const, @@ -426,6 +426,7 @@ describe("#502 unified session view regressions", () => { depth: 0, selectable: true, runningSubagentCount: 0, + recursiveCost: 0, identity: "archived", }; const harness = { @@ -444,7 +445,7 @@ describe("#502 unified session view regressions", () => { 50, ), ); - expect(rendered).toMatch(/123456 · 2h\s*$/); + expect(rendered).toMatch(/↑0 ↓0 · \$0\.00 \(\$0\.00 w\/ subagents\) · 2h\s*$/); }); test("scoped subagent rows keep model and effort ahead of summaries", () => { @@ -467,6 +468,7 @@ describe("#502 unified session view regressions", () => { depth: 1, selectable: true, runningSubagentCount: 0, + recursiveCost: 0, identity: "effort-child", parentIdentity: "parent", }; @@ -487,11 +489,11 @@ describe("#502 unified session view regressions", () => { ), ); - const full = render(120); + const full = render(160); expect(full).toContain( "Inspect agents view · prime-inference/gpt-5.6-terra:high · Investigate a variable background status", ); - const narrow = render(75); + const narrow = render(100); expect(narrow).toContain("prime-inference/gpt-5.6-terra:high"); expect(narrow).not.toContain("Investigate a variable background status"); @@ -505,8 +507,8 @@ describe("#502 unified session view regressions", () => { subagent.summary.thinkingLevel = "off"; subagent.summary.summary = "A later summary"; - expect(render(100)).toContain("Inspect agents view · prime-inference/gpt-5.6-terra · A later summary"); - expect(render(100)).not.toContain(":off"); + expect(render(120)).toContain("Inspect agents view · prime-inference/gpt-5.6-terra · A later summary"); + expect(render(120)).not.toContain(":off"); expect(render(20)).toHaveLength(20); });