diff --git a/apps/desktop/src/main/__tests__/runtime-host-usage-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-usage-ipc-main.test.ts index 6199baa55b..3b7b3df61f 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-usage-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-usage-ipc-main.test.ts @@ -439,6 +439,99 @@ test("settings usage stats truncate the activity log at the cap instead of error assert.equal(stats.logs.filter((row) => row.kind === "model").length, 50_000); }); +test("settings usage stats name each row from the Host-resolved session title", async () => { + const handlers = new Map(); + registerRuntimeHostUsageIpc({ + ipcMain: { + handle: (channel, listener) => handlers.set(channel, listener), + handleReconnectableRead: (channel, listener) => handlers.set(channel, listener), + }, + client: { + queryUsage: async (input: UsageQueryInput) => { + if (input.kind === "summary") { + return { + kind: "summary", + summary: { + range: { from: 1, to: 2 }, + totalRequests: 2, + totalCostUsd: 0, + totalTokens: { + input: 0, + output: 0, + cacheMiss: 0, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + total: 0, + }, + cacheHitRequests: 0, + cacheCreateRequests: 0, + errorRequests: 0, + }, + provenance: provenance(), + } satisfies UsageQueryResult; + } + if (input.kind !== "logs") throw new Error("unexpected usage query"); + // The Host carries `sessionTitle` on the projection (or omits it for + // untitled/unreadable sessions). The desktop layer just surfaces it. + return input.source === "llm" + ? ({ + kind: "logs", + source: "llm", + rows: [ + { + ...llmRow(0), + sessionId: "session-named", + sessionTitle: "重构使用统计页请求日志的任务列", + }, + { ...llmRow(1), sessionId: "session-untitled" }, + ], + offset: 0, + total: 2, + nextOffset: null, + provenance: provenance(), + } satisfies UsageQueryResult) + : ({ + kind: "logs", + source: "tool", + rows: [ + { + ...toolRow(0), + sessionId: "session-named", + sessionTitle: "重构使用统计页请求日志的任务列", + }, + ], + offset: 0, + total: 1, + nextOffset: null, + } satisfies UsageQueryResult); + }, + loadPricingSnapshot: async () => ({ + hostEpoch: "host-epoch", + connectionId: "connection-id", + revision: 0, + entries: [], + }), + } as unknown as DesktopRuntimeHostClient, + sendToRenderer: () => undefined, + }); + + const handler = handlers.get("settings:usageStats"); + assert.ok(handler); + const stats = await handler({} as never, "all") as UsageStats; + // A model row and a tool row carrying the title both surface it as sessionName. + assert.equal( + stats.logs.find((row) => row.id === "llm-0")?.sessionName, + "重构使用统计页请求日志的任务列", + ); + assert.equal( + stats.logs.find((row) => row.id === "tool-0")?.sessionName, + "重构使用统计页请求日志的任务列", + ); + // A row the Host left untitled stays nameless so the UI falls back. + assert.equal(stats.logs.find((row) => row.id === "llm-1")?.sessionName, undefined); +}); + function llmRow(index: number) { return { source: "llm" as const, diff --git a/apps/desktop/src/main/runtime-host-usage-ipc-main.ts b/apps/desktop/src/main/runtime-host-usage-ipc-main.ts index 91a162a0ad..d3b18b6cf3 100644 --- a/apps/desktop/src/main/runtime-host-usage-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-usage-ipc-main.ts @@ -275,12 +275,17 @@ async function loadAllLogs( } } +// The Task column names the session each usage row belongs to. The Host resolves +// the human-readable title (from the durable session header) and carries it on +// the projection as `sessionTitle`; untitled/unreadable sessions omit it, and the +// renderer falls back to the untitled label. function projectLlmLog(row: LlmUsageLogProjection): UsageStats["logs"][number] { return { id: row.id, ts: row.ts, kind: "model", ...(row.sessionId === undefined ? {} : { sessionId: row.sessionId }), + ...(row.sessionTitle === undefined ? {} : { sessionName: row.sessionTitle }), ...(row.turnId === undefined ? {} : { turnId: row.turnId }), provider: row.providerId, model: row.modelId, @@ -302,6 +307,7 @@ function projectToolLog(row: ToolUsageLogProjection): UsageStats["logs"][number] ts: row.ts, kind: "tool", ...(row.sessionId === undefined ? {} : { sessionId: row.sessionId }), + ...(row.sessionTitle === undefined ? {} : { sessionName: row.sessionTitle }), ...(row.turnId === undefined ? {} : { turnId: row.turnId }), provider: row.providerId ?? "", model: row.modelId ?? "", diff --git a/packages/runtime-host/src/__tests__/usage-pricing-protocol.test.ts b/packages/runtime-host/src/__tests__/usage-pricing-protocol.test.ts index 9a5ed0dba2..bed834df9a 100644 --- a/packages/runtime-host/src/__tests__/usage-pricing-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/usage-pricing-protocol.test.ts @@ -208,6 +208,28 @@ describe('Usage/Pricing protocol', () => { nextOffset: null, }), ); + // The Host-resolved session title rides on both log kinds as bounded text. + assert.doesNotThrow(() => + usageResponse({ + kind: 'logs', + source: 'llm', + rows: [{ ...validLog(), sessionId: 'session-1', sessionTitle: '重构任务列' }], + offset: 0, + total: 1, + nextOffset: null, + provenance: validProvenance(), + }), + ); + assert.doesNotThrow(() => + usageResponse({ + kind: 'logs', + source: 'tool', + rows: [{ ...validToolLog(), sessionId: 'session-1', sessionTitle: '重构任务列' }], + offset: 0, + total: 1, + nextOffset: null, + }), + ); const tooMany = Array.from({ length: USAGE_PAGE_MAX_ITEMS + 1 }, () => validBucket()); const byteHeavy = Array.from({ length: 50 }, (_, index) => ({ @@ -244,6 +266,15 @@ describe('Usage/Pricing protocol', () => { nextOffset: null, provenance: validProvenance(), }, + { + kind: 'logs', + source: 'llm', + rows: [{ ...validLog(), sessionTitle: 'x'.repeat(USAGE_PROJECTION_TEXT_MAX_BYTES + 1) }], + offset: 0, + total: 1, + nextOffset: null, + provenance: validProvenance(), + }, { kind: 'logs', source: 'llm', diff --git a/packages/runtime-host/src/__tests__/usage-pricing-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/usage-pricing-two-client-uds.test.ts index ede75459dd..e336e0e90d 100644 --- a/packages/runtime-host/src/__tests__/usage-pricing-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/usage-pricing-two-client-uds.test.ts @@ -295,6 +295,75 @@ test('pricing root identity failure requests drain while expected failures do no }); }); +test('usage logs carry the Host-resolved session title and tolerate unreadable sessions', async () => { + await withUsageAuthority('session-title', async ({ stores }) => { + await Promise.all([ + stores.telemetry.recordLlmCall({ + ...usageRecord('llm-named', 10, 'openai', 'gpt-a'), + sessionId: 'session-named', + turnId: 'turn-1', + }), + stores.telemetry.recordLlmCall({ + ...usageRecord('llm-blank', 11, 'openai', 'gpt-a'), + sessionId: 'session-blank', + turnId: 'turn-2', + }), + stores.telemetry.recordLlmCall({ + ...usageRecord('llm-missing', 12, 'openai', 'gpt-a'), + sessionId: 'session-missing', + turnId: 'turn-3', + }), + stores.telemetry.recordToolInvocation({ + ...toolRecord('tool-a', 13), + sessionId: 'session-named', + }), + ]); + const titles = new Map([ + // Leading/trailing whitespace must be trimmed; a blank title is not a title. + ['session-named', ' 重构使用统计页请求日志的任务列 '], + ['session-blank', ' '], + ]); + const coordinator = new HostUsagePricingCoordinator( + stores, + () => {}, + new RuntimePolicyActivationGate(), + () => {}, + async (sessionId) => { + if (sessionId === 'session-missing') throw new Error('session gone'); + return titles.get(sessionId); + }, + ); + + const llm = await coordinator.handlers['usage.query']( + { kind: 'logs', source: 'llm', query: { range: 'all' }, offset: 0, limit: 100 }, + CONNECTION_CONTEXT, + ); + assert.ok(llm.ok); + assert.equal(llm.result.kind, 'logs'); + if (llm.result.kind !== 'logs' || llm.result.source !== 'llm') + throw new Error('expected llm logs'); + const llmById = new Map(llm.result.rows.map((row) => [row.id, row])); + assert.equal(llmById.get('llm-named')?.sessionTitle, '重构使用统计页请求日志的任务列'); + // Whitespace-only title is dropped; the row stays untitled and the UI falls back. + assert.equal(llmById.get('llm-blank')?.sessionTitle, undefined); + // A session the resolver cannot read is tolerated — one failure never blanks the rest. + assert.equal(llmById.get('llm-missing')?.sessionTitle, undefined); + assert.equal(llmById.get('llm-named')?.sessionId, 'session-named'); + + const tool = await coordinator.handlers['usage.query']( + { kind: 'logs', source: 'tool', query: { range: 'all' }, offset: 0, limit: 100 }, + CONNECTION_CONTEXT, + ); + assert.ok(tool.ok); + if (tool.result.kind !== 'logs' || tool.result.source !== 'tool') + throw new Error('expected tool logs'); + assert.equal( + tool.result.rows.find((row) => row.id === 'tool-a')?.sessionTitle, + '重构使用统计页请求日志的任务列', + ); + }); +}); + test('pricing query rejects continue offsets at and past the effective catalog end', async () => { await withUsageAuthority('pricing-offset', async ({ stores }) => { const coordinator = new HostUsagePricingCoordinator( diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 0092796aee..bfaa5ee28f 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -95,7 +95,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 76 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 77 as const; +// 77: LLM and tool usage-log projections carry an optional `sessionTitle` (the +// Host-resolved session name for the usage Task column). Older Clients reject +// the unknown field, so a newer Host's usage logs are unreadable to them. // 76: Peer Mesh endpoint and Mesh display names are signed, persisted facts // managed through Host operations rather than local-only Client labels. // 75: Peer Mesh routes identify whether a peer is a Client or Runtime Host so diff --git a/packages/runtime-host/src/protocol/usage-pricing.ts b/packages/runtime-host/src/protocol/usage-pricing.ts index 266fa11bab..007bcda93d 100644 --- a/packages/runtime-host/src/protocol/usage-pricing.ts +++ b/packages/runtime-host/src/protocol/usage-pricing.ts @@ -102,6 +102,7 @@ const LLM_USAGE_LOG_FIELDS = new Set([ 'status', 'errorClass', 'sessionId', + 'sessionTitle', 'turnId', ]); const TOOL_USAGE_LOG_FIELDS = new Set([ @@ -121,6 +122,7 @@ const TOOL_USAGE_LOG_FIELDS = new Set([ 'bytesOut', 'startedAt', 'sessionId', + 'sessionTitle', 'turnId', ]); const TOOL_RESULT_SUMMARY_FIELDS = new Set([ @@ -167,6 +169,8 @@ export interface LlmUsageLogProjection { readonly status: 'success' | 'error' | 'aborted'; readonly errorClass?: string; readonly sessionId?: string; + /** Human-readable session title, resolved on the Host; absent for untitled sessions. */ + readonly sessionTitle?: string; readonly turnId?: string; } @@ -187,6 +191,8 @@ export interface ToolUsageLogProjection { readonly bytesOut: number; readonly startedAt: number; readonly sessionId?: string; + /** Human-readable session title, resolved on the Host; absent for untitled sessions. */ + readonly sessionTitle?: string; readonly turnId?: string; } @@ -992,6 +998,7 @@ function decodeLlmUsageLog(value: unknown): LlmUsageLogProjection { status: decodeUsageLogStatus(row.status), ...optionalProjectionText(row, 'errorClass'), ...optionalProjectionText(row, 'sessionId'), + ...optionalProjectionText(row, 'sessionTitle'), ...optionalProjectionText(row, 'turnId'), }; } @@ -1030,6 +1037,7 @@ function decodeToolUsageLog(value: unknown): ToolUsageLogProjection { bytesOut: requireCount(row.bytesOut, 'tool usage log bytes out'), startedAt: nonnegativeFinite(row.startedAt, 'tool usage log start time'), ...optionalProjectionText(row, 'sessionId'), + ...optionalProjectionText(row, 'sessionTitle'), ...optionalProjectionText(row, 'turnId'), }; } diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index ac1b5e85f7..a8eb09122c 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -1106,6 +1106,10 @@ export async function createExecutionRuntimeHostComposition( context.requestDrain, runtimePolicyActivation, registerBackendInvalidation, + // Name the Task column from the durable session header. Reads by id + // straight from the session store, so it also names reserved-role, + // coordination, and legacy sessions the filtered catalog omits. + async (sessionId) => (await stores.sessionStore.readHeaderSnapshot(sessionId)).name, ); const webSearch = new HostWebSearchCoordinator(webSearchService); const networkProxy = new HostNetworkProxyCoordinator(runtimePolicyStores.operations); diff --git a/packages/runtime-host/src/server/usage-pricing-coordinator.ts b/packages/runtime-host/src/server/usage-pricing-coordinator.ts index 01557bd642..c332fa405f 100644 --- a/packages/runtime-host/src/server/usage-pricing-coordinator.ts +++ b/packages/runtime-host/src/server/usage-pricing-coordinator.ts @@ -75,6 +75,10 @@ export class HostUsagePricingCoordinator { readonly #requestDrain: () => void; readonly #activation: RuntimePolicyActivationGate; readonly #onCommittedPricingMutation: () => void; + // Resolves a session's human-readable title for the Task column. Reads the + // durable session header directly (unfiltered, in-process), so it covers + // reserved-role, coordination, and legacy sessions the catalog omits. + readonly #readSessionTitle?: (sessionId: string) => Promise; #poisonDrainRequested = false; constructor( @@ -82,11 +86,38 @@ export class HostUsagePricingCoordinator { requestDrain: () => void, activation: RuntimePolicyActivationGate, onCommittedPricingMutation: () => void = () => {}, + readSessionTitle?: (sessionId: string) => Promise, ) { this.#stores = authenticateInteractiveUsageStoresWriter(stores); this.#requestDrain = requestDrain; this.#activation = activation; this.#onCommittedPricingMutation = onCommittedPricingMutation; + this.#readSessionTitle = readSessionTitle; + } + + // Resolve titles for exactly the sessions on this page. A session we cannot + // read (deleted, or a shape this reader rejects) is simply left untitled — one + // unreadable session never blanks the rest. + async #resolveSessionTitles( + rows: ReadonlyArray<{ readonly sessionId?: string }>, + ): Promise> { + const titles = new Map(); + const read = this.#readSessionTitle; + if (!read) return titles; + const ids = [ + ...new Set(rows.map((row) => row.sessionId).filter((id): id is string => id !== undefined)), + ]; + await Promise.all( + ids.map(async (id) => { + try { + const title = (await read(id))?.trim(); + if (title) titles.set(id, title); + } catch { + // Unreadable session: leave it untitled so the UI falls back. + } + }), + ); + return titles; } /** @@ -154,10 +185,17 @@ export class HostUsagePricingCoordinator { if (input.source === 'tool') { const page = await this.#stores.telemetry.toolLogs(input.query, offset, limit); if (offset > page.total) return invalidUsageOffset(); + const titles = await this.#resolveSessionTitles(page.rows); return { ok: true, result: encodeUsageQueryResult( - usageLogPage('tool', page.rows.map(projectToolUsageLog), page.total, offset, limit), + usageLogPage( + 'tool', + page.rows.map((row) => projectToolUsageLog(row, titles)), + page.total, + offset, + limit, + ), ), }; } @@ -174,12 +212,13 @@ export class HostUsagePricingCoordinator { limit, ); if (offset > merged.total) return invalidUsageOffset(); + const titles = await this.#resolveSessionTitles(merged.rows); return { ok: true, result: encodeUsageQueryResult( usageLogPage( 'llm', - merged.rows.map(projectUsageLog), + merged.rows.map((row) => projectUsageLog(row, titles)), merged.total, offset, limit, @@ -547,9 +586,13 @@ function projectUsageBucket(bucket: UsageBucket): UsageBucket { }; } -function projectUsageLog(row: UsageLogRow): LlmUsageLogProjection { +function projectUsageLog( + row: UsageLogRow, + titles: ReadonlyMap, +): LlmUsageLogProjection { const cacheMissInputSource = (row as UsageLogRow & { readonly cacheMissInputSource?: unknown }) .cacheMissInputSource; + const title = row.sessionId === undefined ? undefined : titles.get(row.sessionId); return { source: 'llm', id: projectIdentity(row.id), @@ -577,6 +620,7 @@ function projectUsageLog(row: UsageLogRow): LlmUsageLogProjection { status: row.status, ...(row.errorClass === undefined ? {} : { errorClass: projectText(row.errorClass) }), ...(row.sessionId === undefined ? {} : { sessionId: projectIdentity(row.sessionId) }), + ...(title === undefined ? {} : { sessionTitle: projectText(title) }), ...(row.turnId === undefined ? {} : { turnId: projectIdentity(row.turnId) }), }; } @@ -588,7 +632,9 @@ function projectToolUsageLog( readonly bytesOut: number; readonly ts: number; }, + titles: ReadonlyMap, ): ToolUsageLogProjection { + const title = row.sessionId === undefined ? undefined : titles.get(row.sessionId); return { source: 'tool', id: projectIdentity(row.id), @@ -616,6 +662,7 @@ function projectToolUsageLog( bytesOut: row.bytesOut, startedAt: row.startedAt, ...(row.sessionId === undefined ? {} : { sessionId: projectIdentity(row.sessionId) }), + ...(title === undefined ? {} : { sessionTitle: projectText(title) }), ...(row.turnId === undefined ? {} : { turnId: projectIdentity(row.turnId) }), }; }