Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, IpcHandler>();
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,
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/main/runtime-host-usage-ipc-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 ?? "",
Expand Down
31 changes: 31 additions & 0 deletions packages/runtime-host/src/__tests__/usage-pricing-protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
5 changes: 4 additions & 1 deletion packages/runtime-host/src/protocol/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = 74 as const;
export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 75 as const;
// 75: 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.
// 74: Capability-provider credentials may carry one Host-authenticated owner
// identity. Older peers cannot preserve the association and could select an
// unrelated provider for an interactive Session.
Expand Down
8 changes: 8 additions & 0 deletions packages/runtime-host/src/protocol/usage-pricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ const LLM_USAGE_LOG_FIELDS = new Set([
'status',
'errorClass',
'sessionId',
'sessionTitle',
'turnId',
]);
const TOOL_USAGE_LOG_FIELDS = new Set([
Expand All @@ -121,6 +122,7 @@ const TOOL_USAGE_LOG_FIELDS = new Set([
'bytesOut',
'startedAt',
'sessionId',
'sessionTitle',
'turnId',
]);
const TOOL_RESULT_SUMMARY_FIELDS = new Set([
Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
}

Expand Down Expand Up @@ -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'),
};
}
Expand Down Expand Up @@ -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'),
};
}
Expand Down
4 changes: 4 additions & 0 deletions packages/runtime-host/src/server/execution-composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading