From d2d5c78e6ca38cd20abaf3b12c4de545927d84df Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 17 Aug 2026 10:36:07 +0800 Subject: [PATCH 01/20] fix(runtime-host): page Session traces at the source Read the latest trace page without materializing the full Session, expose stable older-history cursors, and keep the Inspector's usage estimate scoped to the complete Session. Generated-by: Codex --- .../session-inspector-panel-model.test.ts | 36 ++++ .../main/__tests__/use-session-trace.test.ts | 43 +++- .../main/runtime-host-renderer-ipc-main.ts | 2 + apps/desktop/src/preload/bridge-contract.d.ts | 15 +- apps/desktop/src/preload/preload.ts | 92 ++++----- .../runtime-host-renderer-operations.ts | 1 + .../src/renderer/locales/conversation-copy.ts | 39 ++-- .../src/renderer/session-inspector-filter.ts | 132 ------------- .../session-inspector-overview-model.ts | 67 +++---- .../src/renderer/session-inspector-panel.tsx | 122 +++++------- .../src/renderer/styles/chat-detail.css | 35 ---- .../desktop/src/renderer/use-session-trace.ts | 184 +++++++++++++++++- .../model-call-usage-projection.test.ts | 13 +- .../core/src/model-call-usage-projection.ts | 1 + packages/core/src/usage-stats/types.ts | 1 + .../execution-inspect-coordinator.test.ts | 108 ++++++---- .../execution-inspect-protocol.test.ts | 6 +- .../src/__tests__/protocol.test.ts | 6 +- .../__tests__/usage-pricing-protocol.test.ts | 2 + .../src/protocol/execution-inspect.ts | 91 ++------- packages/runtime-host/src/protocol/index.ts | 4 +- .../src/protocol/usage-pricing.ts | 2 + .../server/execution-inspect-coordinator.ts | 165 +++++++--------- packages/storage/src/agent-run-store.ts | 77 ++++++++ packages/storage/src/execution-stores.ts | 9 + packages/storage/src/sqlite-usage-store.ts | 1 + 26 files changed, 670 insertions(+), 584 deletions(-) delete mode 100644 apps/desktop/src/renderer/session-inspector-filter.ts diff --git a/apps/desktop/src/main/__tests__/session-inspector-panel-model.test.ts b/apps/desktop/src/main/__tests__/session-inspector-panel-model.test.ts index 18beffc75f..23775e6c2b 100644 --- a/apps/desktop/src/main/__tests__/session-inspector-panel-model.test.ts +++ b/apps/desktop/src/main/__tests__/session-inspector-panel-model.test.ts @@ -6,6 +6,42 @@ import { type SessionTrace, } from '@maka/core/session-trace'; import { deriveInspectorPanelModel } from '../../renderer/session-inspector-panel-model.js'; +import { estimatedSessionCost } from '../../renderer/session-inspector-overview-model.js'; + +test('does not render legacy zero cost as a known free Session', () => { + const summary = { + range: { from: 0, to: 1 }, + totalRequests: 1, + totalCostUsd: 0, + totalTokens: { + input: 1, + output: 1, + cacheMiss: 1, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + total: 2, + }, + cacheHitRequests: 0, + cacheCreateRequests: 0, + errorRequests: 0, + provenance: { + coverage: { + attempts: 0, + pricedAttempts: 0, + unpricedAttempts: 0, + usageReportedAttempts: 0, + usagePartialAttempts: 0, + usageMissingAttempts: 0, + }, + legacyRecords: 1, + unreadableRecords: 0, + pendingRepairs: 0, + }, + }; + assert.equal(estimatedSessionCost(summary), undefined); + assert.equal(estimatedSessionCost({ ...summary, totalCostUsd: 0.01 }), 0.01); +}); test('shows one compact diagnostic line for a failed history-compaction call', () => { const trace: SessionTrace = { diff --git a/apps/desktop/src/main/__tests__/use-session-trace.test.ts b/apps/desktop/src/main/__tests__/use-session-trace.test.ts index 1528412268..7098a75037 100644 --- a/apps/desktop/src/main/__tests__/use-session-trace.test.ts +++ b/apps/desktop/src/main/__tests__/use-session-trace.test.ts @@ -8,6 +8,7 @@ import { } from '@maka/core/session-trace'; import type { SessionEvent } from '@maka/core/events'; import type { Result } from '@maka/core/result'; +import type { DesktopSessionTracePage } from '../../preload/bridge-contract.js'; import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; import { TRACE_REFRESH_DEBOUNCE_MS, @@ -39,6 +40,7 @@ function trace(sessionId: string): SessionTrace { interface TraceHarness { reads: string[]; contextReads: string[]; + summaryReads: string[]; emit: (event: SessionEvent) => void; subscriptions: number; unsubscribes: number; @@ -49,6 +51,7 @@ function installMakaBridge(): TraceHarness { const harness: TraceHarness = { reads: [], contextReads: [], + summaryReads: [], emit: (event) => { for (const handler of [...handlers]) handler(event); }, @@ -59,9 +62,45 @@ function installMakaBridge(): TraceHarness { // replaces `globalThis.window`, so building one here first would be clobbered. (globalThis.window as unknown as { maka: unknown }).maka = { inspector: { - trace: async (sessionId: string): Promise> => { + trace: async (sessionId: string): Promise> => { harness.reads.push(sessionId); - return { ok: true, data: trace(sessionId) }; + return { ok: true, data: { trace: trace(sessionId), nextCursor: null } }; + }, + summary: async (sessionId: string) => { + harness.summaryReads.push(sessionId); + return { + ok: true as const, + data: { + range: { from: 0, to: 1 }, + totalRequests: 0, + totalCostUsd: 0, + totalTokens: { + input: 0, + output: 0, + cacheMiss: 0, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + total: 0, + }, + cacheHitRequests: 0, + cacheCreateRequests: 0, + errorRequests: 0, + provenance: { + coverage: { + attempts: 0, + pricedAttempts: 0, + unpricedAttempts: 0, + usageReportedAttempts: 0, + usagePartialAttempts: 0, + usageMissingAttempts: 0, + }, + legacyRecords: 0, + unreadableRecords: 0, + pendingRepairs: 0, + }, + }, + }; }, // The hook reads the context snapshot on the same signal (#2323). It // is counted separately: the assertions below are about how often the diff --git a/apps/desktop/src/main/runtime-host-renderer-ipc-main.ts b/apps/desktop/src/main/runtime-host-renderer-ipc-main.ts index 9bb25af465..e9d187a10f 100644 --- a/apps/desktop/src/main/runtime-host-renderer-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-renderer-ipc-main.ts @@ -81,6 +81,8 @@ function request( return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value)); case 'scheduled-task.query': return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value)); + case 'usage.query': + return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value)); case 'web-search.execute': return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value)); } diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index c1944224f6..81f227c1f3 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -89,6 +89,8 @@ import type { RendererRuntimeHostQueryOperation, } from './runtime-host-renderer-operations.js'; import type { SessionTrace } from '@maka/core/session-trace'; +import type { UsageSummaryV2 } from '@maka/core/usage-stats/types'; +import type { UsageProvenance } from '@maka/core/usage-ledger-merge'; import type { ContextDiagnosticsResult } from '@maka/runtime-host/protocol'; import type { TestProxyInput } from '@maka/core/settings/network-settings'; import type { ExternalSessionImportIpcResult } from './external-session-import-result.js'; @@ -413,6 +415,15 @@ export interface PetPackChangedEvent { readonly ts: number; } +export interface DesktopSessionTracePage { + readonly trace: SessionTrace; + readonly nextCursor: string | null; +} + +export interface DesktopSessionUsageSummary extends UsageSummaryV2 { + readonly provenance: UsageProvenance; +} + export interface MakaBridge { runtimeHost: { query( @@ -988,7 +999,9 @@ export interface MakaBridge { }; inspector: { /** Read-only per-session causal trace (#1625). */ - trace(sessionId: string): Promise>; + trace(sessionId: string, cursor?: string): Promise>; + /** Complete Session-scoped LLM usage estimate, independent of loaded trace pages. */ + summary(sessionId: string): Promise>; /** What the session's context is made of right now (#2323). */ context(sessionId: string): Promise>; }; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index c32666b714..74718101ee 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -31,6 +31,8 @@ import type { DesktopRuntimeHostRef, DesktopProjectSnapshot, DesktopAppInfo, + DesktopSessionTracePage, + DesktopSessionUsageSummary, } from './bridge-contract.js'; import type { ExternalSessionImportIpcResult } from './external-session-import-result.js'; import { @@ -128,7 +130,6 @@ import { } from '@maka/core/web-search'; import { isSessionTrace, - type SessionTrace, } from '@maka/core/session-trace'; import type { ContextDiagnosticsResult } from '@maka/runtime-host/protocol'; import { @@ -804,56 +805,42 @@ async function mutateScheduledTask( return result.task; } -async function loadSessionTrace(sessionId: string): Promise { +async function loadSessionTracePage( + sessionId: string, + cursor?: string, +): Promise { const session = await runtimeHostSessionRef(sessionId); const host = scopedRuntimeHost(session.scope); - for (let attempt = 0; attempt < 3; attempt += 1) { - const first = await host.query('execution.inspect.query', { - kind: 'session_trace_start', - sessionId: session.sessionId, - }); - if (first.kind !== 'session_trace_page') throw new Error('Invalid Session trace page'); - const turns = [...first.turns]; - const offsets = new Set([0]); - let nextOffset = first.nextOffset; - let retry = false; - while (nextOffset !== null) { - if (offsets.has(nextOffset)) throw new Error('Session trace repeated a page offset'); - offsets.add(nextOffset); - const next = await host.query('execution.inspect.query', { - kind: 'session_trace_continue', - sessionId: session.sessionId, - revision: first.revision, - offset: nextOffset, - }); - if (next.kind === 'session_trace_revision_changed') { - retry = true; - break; - } - if ( - next.kind !== 'session_trace_page' || - next.revision !== first.revision || - next.offset !== nextOffset || - JSON.stringify(next.totals) !== JSON.stringify(first.totals) || - JSON.stringify(next.coverage) !== JSON.stringify(first.coverage) - ) { - throw new Error('Invalid Session trace continuation'); - } - turns.push(...next.turns); - nextOffset = next.nextOffset; - } - if (retry) continue; - const trace = { - schemaVersion: first.schemaVersion, - sessionId, - turns, - totals: first.totals, - coverage: first.coverage, - }; - if (!isSessionTrace(trace)) throw new Error('Invalid Session trace projection'); - return trace; - } - throw new Error('Session trace kept changing while Desktop read it'); + const page = await host.query( + 'execution.inspect.query', + cursor + ? { + kind: 'session_trace_continue', + sessionId: session.sessionId, + cursor, + } + : { kind: 'session_trace_start', sessionId: session.sessionId }, + ); + if (page.kind !== 'session_trace_page') throw new Error('Invalid Session trace page'); + const trace = { + schemaVersion: page.schemaVersion, + sessionId, + turns: [...page.turns], + totals: page.totals, + coverage: page.coverage, + }; + if (!isSessionTrace(trace)) throw new Error('Invalid Session trace projection'); + return { trace, nextCursor: page.nextCursor }; +} + +async function loadSessionUsageSummary(sessionId: string): Promise { + const session = await runtimeHostSessionRef(sessionId); + const result = await scopedRuntimeHost(session.scope).query('usage.query', { + kind: 'summary', + query: { range: 'all', sessionId: session.sessionId }, + }); + if (result.kind !== 'summary') throw new Error('Invalid Session usage summary'); + return { ...result.summary, provenance: result.provenance }; } async function updateDailyReviewConfig( @@ -2386,8 +2373,11 @@ const makaBridge = { }, inspector: { /** Read-only per-session causal trace (#1625). Never writes runtime state. */ - trace(sessionId: string): Promise> { - return bridgeResult(() => loadSessionTrace(sessionId), 'INSPECTOR_TRACE_FAILED'); + trace(sessionId: string, cursor?: string): Promise> { + return bridgeResult(() => loadSessionTracePage(sessionId, cursor), 'INSPECTOR_TRACE_FAILED'); + }, + summary(sessionId: string): Promise> { + return bridgeResult(() => loadSessionUsageSummary(sessionId), 'INSPECTOR_SUMMARY_FAILED'); }, /** * What the session's context is made of right now (#2323). diff --git a/apps/desktop/src/preload/runtime-host-renderer-operations.ts b/apps/desktop/src/preload/runtime-host-renderer-operations.ts index e0dde01165..0c855ec748 100644 --- a/apps/desktop/src/preload/runtime-host-renderer-operations.ts +++ b/apps/desktop/src/preload/runtime-host-renderer-operations.ts @@ -8,6 +8,7 @@ export const RENDERER_RUNTIME_HOST_QUERY_OPERATIONS = [ 'daily-review.query', 'execution.inspect.query', 'scheduled-task.query', + 'usage.query', ] as const satisfies readonly (keyof OperationSpecMap)[]; export const RENDERER_RUNTIME_HOST_COMMAND_OPERATIONS = [ diff --git a/apps/desktop/src/renderer/locales/conversation-copy.ts b/apps/desktop/src/renderer/locales/conversation-copy.ts index 4db569105f..708e4faebf 100644 --- a/apps/desktop/src/renderer/locales/conversation-copy.ts +++ b/apps/desktop/src/renderer/locales/conversation-copy.ts @@ -161,6 +161,11 @@ export interface DesktopConversationCopy { /** The panel-empty (tier 2) sentence under `empty`. */ emptyHelp: string; costUnavailable: string; + costEstimateHelp: string; + loadEarlier: string; + loadingEarlier: string; + loadingSummary: string; + summaryUnavailable: string; /** Labels for the two headline figures the trace always states. */ totals: { duration: string; @@ -197,14 +202,6 @@ export interface DesktopConversationCopy { * reader gets, with a plain fallback for a code nobody has named yet. */ turnFailure: (code: string) => string; - filterLabel: string; - filterPlaceholder: string; - /** The failure count that doubles as the "only failures" toggle. */ - filterFailedOnly: (count: number) => string; - noMatches: string; - /** The filter no-match's clear action. */ - clearFilter: string; - hiddenByFilter: (count: number) => string; /** Display name of one turn in the raw record: 第 N 轮 / Turn N. */ turnLabel: (index: number) => string; /** Summary above the raw timeline. */ @@ -481,9 +478,14 @@ const COPY = { empty: '这个任务还没有可追踪的活动', emptyHelp: '任务尚无活动记录。', costUnavailable: '费用未知', + costEstimateHelp: '基于当前定价和已记录用量估算;缺失或未定价的调用可能未计入。', + loadEarlier: '加载更早记录', + loadingEarlier: '正在加载…', + loadingSummary: '正在估算完整会话用量…', + summaryUnavailable: '完整会话用量暂时无法估算。', totals: { duration: '总耗时', - cost: '花费', + cost: '估算成本', }, coveragePartial: (parts) => `部分调用没有留下记录,下面的数字只少不多${zhDetail(parts)}`, coverageAbsent: (parts) => `这个后端不记录每次调用的明细${zhDetail(parts)}`, @@ -496,12 +498,6 @@ const COPY = { recoveredAs: (disposition) => `已恢复:${ZH_RECOVERED[disposition] ?? disposition}`, retries: (count) => `重试 ${count} 次`, turnFailure: (code) => ZH_TURN_FAILURE[code] ?? '本轮失败', - filterLabel: '筛选追踪', - filterPlaceholder: '按工具、模型或轮次筛选', - filterFailedOnly: (count) => `${count} 轮失败`, - noMatches: '没有匹配的记录', - clearFilter: '清除筛选', - hiddenByFilter: (count) => `已隐藏 ${count} 项`, turnLabel: (index) => `第 ${index} 轮`, overview: { context: '上下文窗口', @@ -683,9 +679,14 @@ const COPY = { empty: 'Nothing to trace in this task yet', emptyHelp: 'No activity recorded for this task yet.', costUnavailable: 'cost unknown', + costEstimateHelp: 'Estimated from current pricing and recorded usage; missing or unpriced calls may be excluded.', + loadEarlier: 'Load earlier records', + loadingEarlier: 'Loading…', + loadingSummary: 'Estimating full-session usage…', + summaryUnavailable: 'Full-session usage is temporarily unavailable.', totals: { duration: 'Duration', - cost: 'Cost', + cost: 'Estimated cost', }, coveragePartial: (parts) => `Some calls left no record, so the numbers below only undercount${enDetail(parts)}`, @@ -700,12 +701,6 @@ const COPY = { recoveredAs: (disposition) => `recovered as ${disposition}`, retries: (count) => `${count} retr${count === 1 ? 'y' : 'ies'}`, turnFailure: (code) => EN_TURN_FAILURE[code] ?? 'Turn failed', - filterLabel: 'Filter the trace', - filterPlaceholder: 'Filter by tool, model or turn', - filterFailedOnly: (count) => `${count} failed turn${count === 1 ? '' : 's'}`, - noMatches: 'Nothing matches this filter', - clearFilter: 'Clear filters', - hiddenByFilter: (count) => `${count} hidden by the filter`, turnLabel: (index) => `Turn ${index}`, overview: { context: 'Context window', diff --git a/apps/desktop/src/renderer/session-inspector-filter.ts b/apps/desktop/src/renderer/session-inspector-filter.ts deleted file mode 100644 index 42e0098295..0000000000 --- a/apps/desktop/src/renderer/session-inspector-filter.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { - type InspectorCopy, - inspectorStepKindLabel, -} from './locales/conversation-copy.js'; -import type { InspectorPanelModel, InspectorStepRow, InspectorTurnRow } from './session-inspector-panel-model.js'; - -/** - * Filtering the Inspector timeline (#1625). - * - * Pure predicates over the projected trace, deliberately not a query API on the - * contract: the questions a reader brings to a trace are `Array.filter` over - * `TraceStep[]`, and inventing a query language for them would be surface - * nobody needs. - * - * Two predicates, because they are the two the panel can produce. Kind and cost - * filters are easy to write here and were, but a predicate no control emits is - * dead surface with tests that guard nothing; they come back when the controls - * that drive them are designed. - * - * Free text matches what each row renders, in the language it renders in: the - * localized name, qualifier and recovery of a step, and the turn's own display - * name. It takes the copy for that reason — the trace's raw identifiers - * (`semantic_compact`, `allow`, `turn-7`) are not on screen, so matching them - * would answer a reader who typed what they saw with silence. - * - * It deliberately does **not** reach into event bodies: that is - * `searchRuntimeEventHistory`'s job, and shipping every tool result into the - * renderer to imitate it would multiply a payload this panel is already asked - * to bound, with silent truncation as the only way out. A filter that quietly - * stops matching is worse than one with a stated reach. - */ -export interface InspectorFilter { - /** Case-insensitive substring over the fields each row renders. */ - query?: string; - /** Only turns that failed. */ - failedOnly?: boolean; -} - -export interface FilteredInspectorModel extends InspectorPanelModel { - /** - * Whether a filter is narrowing the view. Kept separate from `empty` so the - * panel can say "nothing matches" instead of "nothing happened" — a filtered - * timeline that reads as an idle session is the same lie as an unreported - * coverage gap, one layer up. - */ - filtered: boolean; - /** Turns present in the trace but hidden by the filter. */ - hiddenTurns: number; - /** Steps hidden inside the turns that still render. */ - hiddenSteps: number; -} - -export function isEmptyInspectorFilter(filter: InspectorFilter | undefined): boolean { - if (!filter) return true; - return !filter.query?.trim() && !filter.failedOnly; -} - -export function applyInspectorFilter( - model: InspectorPanelModel, - filter: InspectorFilter | undefined, - copy: InspectorCopy, -): FilteredInspectorModel { - if (isEmptyInspectorFilter(filter)) { - return { ...model, filtered: false, hiddenTurns: 0, hiddenSteps: 0 }; - } - const active = filter!; - const query = active.query?.trim().toLowerCase(); - - let hiddenSteps = 0; - const turns: InspectorTurnRow[] = []; - for (const turn of model.turns) { - if (active.failedOnly && !turn.failed) continue; - const steps = turn.steps.filter((step) => matchesStep(step, query, copy)); - // A turn with no matching step is still kept when the filter is about the - // turn itself: its own name matched, or there is no text query at all and - // it already passed the outcome gate above. Dropping it would answer "where - // is turn 7" — or "show me what failed", for a turn that failed before it - // recorded a step — with silence. - const turnMatches = - query === undefined ? true : turnSearchText(turn, copy).includes(query); - if (steps.length === 0 && !turnMatches) continue; - hiddenSteps += turn.steps.length - steps.length; - turns.push({ ...turn, steps }); - } - - return { - ...model, - turns, - filtered: true, - hiddenTurns: model.turns.length - turns.length, - hiddenSteps, - // `empty` stays a statement about the session, not about the filter. The - // panel distinguishes the two; collapsing them here would make a narrow - // filter indistinguishable from an empty session. - empty: model.empty, - }; -} - -function matchesStep(step: InspectorStepRow, query: string | undefined, copy: InspectorCopy): boolean { - // The turn-level gate is applied by the caller; repeating it here would be a - // second place to keep in step with it. - if (query === undefined || query === '') return true; - return stepSearchText(step, copy).includes(query); -} - -/** - * Exactly the text this row draws — the filter's reach is what you see. - * - * Turn-level text is deliberately absent: folding a turn's failure into every - * step's haystack would make one match retain steps that explain nothing about - * it. The turn's own name is matched against the turn, where it belongs. - */ -function stepSearchText(step: InspectorStepRow, copy: InspectorCopy): string { - return [ - step.label ?? inspectorStepKindLabel(copy, step.kind), - step.detail, - step.callKind !== undefined ? copy.callKind(step.callKind) : undefined, - step.decision !== undefined ? copy.permissionDecision(step.decision) : undefined, - step.recovered !== undefined ? copy.recoveredAs(step.recovered) : undefined, - ] - .filter((part): part is string => typeof part === 'string') - .join(' ') - .toLowerCase(); -} - -/** The turn's display name, plus the failure it announces beside it. */ -function turnSearchText(turn: InspectorTurnRow, copy: InspectorCopy): string { - return [copy.turnLabel(turn.index), turn.failed ? copy.turnFailure(turn.failureCode ?? '') : undefined] - .filter((part): part is string => typeof part === 'string') - .join(' ') - .toLowerCase(); -} diff --git a/apps/desktop/src/renderer/session-inspector-overview-model.ts b/apps/desktop/src/renderer/session-inspector-overview-model.ts index d6065d5286..d8942289df 100644 --- a/apps/desktop/src/renderer/session-inspector-overview-model.ts +++ b/apps/desktop/src/renderer/session-inspector-overview-model.ts @@ -2,12 +2,9 @@ import type { ContextDiagnosticsResult, ContextDiagnosticsSegment, } from '@maka/runtime-host/protocol'; -import type { - SessionTrace, - TraceModelAttempt, - TraceModelCallStep, - TurnTrace, -} from '@maka/core/session-trace'; +import type { SessionTrace } from '@maka/core/session-trace'; +import type { UsageSummaryV2 } from '@maka/core/usage-stats/types'; +import type { UsageProvenance } from '@maka/core/usage-ledger-merge'; /** * Overview view model for the Inspector panel's summary sections. @@ -125,18 +122,32 @@ export interface InspectorOverviewModel { cacheHitRate?: number; } +export function estimatedSessionCost( + summary: (UsageSummaryV2 & { readonly provenance: UsageProvenance }) | undefined, +): number | undefined { + if (!summary) return undefined; + if (summary.provenance.coverage.pricedAttempts > 0) return summary.totalCostUsd; + // Legacy records never stored a cost basis. A non-zero amount is still a + // useful estimate, but zero cannot distinguish a genuinely free call from + // one whose price was never resolved. + return summary.provenance.legacyRecords > 0 && summary.totalCostUsd > 0 + ? summary.totalCostUsd + : undefined; +} + /** * The overview reads two owners, and keeps them apart. * - * The trace answers what happened and what it cost. The context snapshot - * answers what the context holds right now — its own Host operation, the one - * `/context` prints (#1580, #2323). Neither is derived from the other, so a - * session with a trace and no snapshot still shows its history, and a snapshot - * with no trace still sizes the window. + * The trace answers what happened. The Session-scoped usage summary answers + * what all recorded calls cost, independently of which trace pages are loaded. + * The context snapshot answers what the context holds right now — its own Host + * operation, the one `/context` prints (#1580, #2323). None is derived from + * another, so one unavailable source cannot falsify the others. */ export function deriveInspectorOverviewModel( trace: SessionTrace | undefined, diagnostics?: ContextDiagnosticsResult, + usage?: UsageSummaryV2, ): InspectorOverviewModel { // Both halves of the context block come from the SAME snapshot. They used to // be picked separately — the bar from the latest trace attempt that carried a @@ -145,16 +156,15 @@ export function deriveInspectorOverviewModel( // contents. One source cannot disagree with itself (#2323). const composition = compositionState(diagnostics); const context = contextBudget(diagnostics); + const cacheHitRate = usageCacheHitRate(usage); if (!trace || trace.turns.length === 0) { return { ...(context ? { context } : {}), ...(composition ? { composition } : {}), + ...(cacheHitRate !== undefined ? { cacheHitRate } : {}), }; } - const modelSteps = trace.turns.flatMap(modelCallSteps); - const cacheHitRate = sessionCacheHitRate(modelSteps.flatMap((step) => step.attempts)); - return { ...(context ? { context } : {}), ...(composition ? { composition } : {}), @@ -162,28 +172,9 @@ export function deriveInspectorOverviewModel( }; } -function modelCallSteps(turn: TurnTrace): TraceModelCallStep[] { - return turn.steps.filter((step): step is TraceModelCallStep => step.kind === 'model_call'); -} - -/** - * Summed over the attempts that reported input, and undefined when none did: - * a rate over nothing is unknown, not zero, the same way "did not report" and - * "reported none" stay apart in the ledger (#1679). - * - * An input-reported attempt without a cache figure reads as a miss, because - * the providers that cache always count the hits. - * - * Each attempt's cache read is clamped to its own prompt, the same way the - * context bar clamps it: a provider can report more cache than prompt — the - * runtime's Google mapping guards against exactly that — and a share of a - * prompt over 100% is not a fact, it is corruption wearing a percent sign. - */ -function sessionCacheHitRate(attempts: readonly TraceModelAttempt[]): number | undefined { - const input = attempts.filter((attempt) => attempt.inputTokens !== undefined); - const inputTokens = sum(input, (attempt) => attempt.inputTokens); - if (inputTokens === 0) return undefined; - return sum(input, (attempt) => Math.min(attempt.cacheReadInputTokens ?? 0, attempt.inputTokens!)) / inputTokens; +function usageCacheHitRate(usage: UsageSummaryV2 | undefined): number | undefined { + if (!usage || usage.totalTokens.input === 0) return undefined; + return Math.min(usage.totalTokens.cacheRead, usage.totalTokens.input) / usage.totalTokens.input; } /** @@ -292,7 +283,3 @@ function compositionState( function estimateTokens(bytes: number): number { return Math.ceil(bytes / 4); } - -function sum(attempts: readonly TraceModelAttempt[], pick: (attempt: TraceModelAttempt) => number | undefined): number { - return attempts.reduce((carry, attempt) => carry + (pick(attempt) ?? 0), 0); -} diff --git a/apps/desktop/src/renderer/session-inspector-panel.tsx b/apps/desktop/src/renderer/session-inspector-panel.tsx index d91cedd9a5..872c9735f6 100644 --- a/apps/desktop/src/renderer/session-inspector-panel.tsx +++ b/apps/desktop/src/renderer/session-inspector-panel.tsx @@ -6,11 +6,8 @@ import { Heading } from '@astryxdesign/core/Heading'; import { HStack, VStack } from '@astryxdesign/core/Layout'; import { Section } from '@astryxdesign/core/Section'; import { Text } from '@astryxdesign/core/Text'; -import { TextInput } from '@astryxdesign/core/TextInput'; -import { ToggleButton } from '@astryxdesign/core/ToggleButton'; import { Tooltip } from '@astryxdesign/core/Tooltip'; import { uiLocaleToIntlLocale, type UiLocale } from '@maka/core/ui-locale'; -import type { TraceTotals } from '@maka/core/session-trace'; import { useToast, useUiLocale } from '@maka/ui'; import { ICON_SIZE, Activity, AlertTriangle, Copy } from '@maka/ui/icons'; import { @@ -18,8 +15,10 @@ import { type InspectorCopy, inspectorStepKindLabel, } from './locales/conversation-copy.js'; -import { applyInspectorFilter, type InspectorFilter } from './session-inspector-filter.js'; -import { deriveInspectorOverviewModel } from './session-inspector-overview-model.js'; +import { + deriveInspectorOverviewModel, + estimatedSessionCost, +} from './session-inspector-overview-model.js'; import { deriveInspectorPanelModel, type InspectorStepRow, @@ -27,7 +26,6 @@ import { } from './session-inspector-panel-model.js'; import { useSessionTrace } from './use-session-trace.js'; - /** * The record file is the workspace's operational-state database — the file * both trace ledgers live in. Its exact path is resolved once, in main @@ -74,17 +72,11 @@ export function SessionInspectorPanel(props: { sessionId: string; active: boolea loadFailed: copy.loadFailed, locale, }); - const [filter, setFilter] = useState({}); - const trace = useMemo(() => deriveInspectorPanelModel(snapshot.trace), [snapshot.trace]); - const model = useMemo(() => applyInspectorFilter(trace, filter, copy), [trace, filter, copy]); + const model = useMemo(() => deriveInspectorPanelModel(snapshot.trace), [snapshot.trace]); const overview = useMemo( - () => deriveInspectorOverviewModel(snapshot.trace, snapshot.context), - [snapshot.trace, snapshot.context], + () => deriveInspectorOverviewModel(snapshot.trace, snapshot.context, snapshot.summary), + [snapshot.trace, snapshot.context, snapshot.summary], ); - // Counted on the unfiltered trace, so turning the filter on cannot change - // the number that named it. - const failedTurns = trace.turns.filter((turn) => turn.failed).length; - const hidden = model.hiddenTurns + model.hiddenSteps; // The record file is a fact about the workspace, not about the session's // activity: it exists whether the trace is empty or not, and it never @@ -143,7 +135,7 @@ export function SessionInspectorPanel(props: { sessionId: string; active: boolea className="maka-inspector-panel" data-maka-contract="session-inspector" aria-label={copy.ariaLabel} - aria-busy={snapshot.loading || undefined} + aria-busy={snapshot.loading || snapshot.summaryLoading || undefined} > {/* 24px between blocks against 8px inside one: proximity is the only grouping tool a panel without boxes has, and it used to spend the @@ -210,7 +202,7 @@ export function SessionInspectorPanel(props: { sessionId: string; active: boolea className="maka-inspector-status" data-empty={model.empty || undefined} > - {model.empty && !snapshot.loading && !snapshot.error && ( + {model.empty && !snapshot.nextCursor && !snapshot.loading && !snapshot.error && (