Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
25f1c95
feat(usage): filter usage queries by model call kind
ggbdpq Sep 24, 2026
7a191e0
chore(protocol): bump the compatibility epoch for callKinds usage que…
ggbdpq Sep 24, 2026
79dbb0b
chore: retrigger CI
ggbdpq Sep 24, 2026
14de3ce
fix(desktop): hide the main cache rate when the main-only summary rea…
ggbdpq Sep 26, 2026
3ce9980
test(desktop): exercise the injected preload failure behind the usage…
ggbdpq Sep 27, 2026
3aad406
chore: retrigger CI (workhub browser presentation smoke flake)
ggbdpq Sep 27, 2026
e7e5cf4
fix(desktop,storage): degrade a crashed main-only usage read; documen…
ggbdpq Sep 27, 2026
ce5b42d
chore: retrigger CI (head event was not registered)
ggbdpq Sep 28, 2026
04d5f35
Merge branch 'main' into feat/usage-call-kind-filter
ggbdpq Sep 28, 2026
f3a3533
Merge branch 'main' into feat/usage-call-kind-filter
ggbdpq Sep 28, 2026
46e457c
Merge branch 'main' into feat/usage-call-kind-filter
ggbdpq Sep 28, 2026
f2d44c5
Merge upstream/main into feat/usage-call-kind-filter
ggbdpq Sep 30, 2026
0a0d0fd
Merge upstream/main (d7dffca98)
ggbdpq Sep 30, 2026
2d7ea34
Merge upstream/main (9b089f58e)
ggbdpq Sep 30, 2026
671bc60
Merge remote-tracking branch 'upstream/main' into feat/usage-call-kin…
ggbdpq Oct 1, 2026
2aed892
Merge remote-tracking branch 'upstream/main' into feat/usage-call-kin…
ggbdpq Oct 3, 2026
e14a1b7
Merge upstream/main (3597abe84): sixth epoch bump 205 -> 208
ggbdpq Oct 5, 2026
f36fe99
Merge remote-tracking branch 'upstream/main' into feat/usage-call-kin…
ggbdpq Oct 8, 2026
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
150 changes: 150 additions & 0 deletions apps/desktop/src/main/__tests__/session-inspector-usage-stats.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ import {
usageRingArcs,
} from '../../renderer/features/workbar/testing.js';
import type { UsageSummaryV2 } from '@maka/core/usage-stats/types';
import type { DesktopSessionUsageSummary } from '../../preload/bridge-contract.js';
import {
loadSessionUsageSummaryVia,
type UsageSummaryInvoke,
} from '../../preload/usage-summary.js';

function usageSummary(overrides: Partial<UsageSummaryV2> = {}): UsageSummaryV2 {
return {
Expand All @@ -50,6 +55,22 @@ function usageSummary(overrides: Partial<UsageSummaryV2> = {}): UsageSummaryV2 {
};
}

function fullCoverageProvenance(): DesktopSessionUsageSummary['provenance'] {
return {
coverage: {
attempts: 3,
pricedAttempts: 3,
unpricedAttempts: 0,
usageReportedAttempts: 3,
usagePartialAttempts: 0,
usageMissingAttempts: 0,
},
legacyRecords: 0,
unreadableRecords: 0,
pendingRepairs: 0,
};
}

test('splits the session metered tokens the way a bill reads', () => {
const { tokenUsage } = deriveInspectorOverviewModel(
undefined,
Expand Down Expand Up @@ -353,3 +374,132 @@ test('carries rounded inspector durations into the next unit', () => {
assert.equal(formatDuration(3_599_600), '60m0s');
assert.equal(formatDuration(125_000), '2m5s');
});

test('reads the cache rate from the main loop alone when the main summary is available', () => {
// A 10k-token uncached suggestion call drops a 95%-cached main loop to a
// blended 86%: auxiliary prompts keep their own prefix, so the rate must
// read the main summary, not the blend (#5691).
const blended = usageSummary({
totalTokens: {
input: 1_100_000,
output: 60_300,
cacheMiss: 150_000,
cacheRead: 950_000,
cacheWrite: 0,
reasoning: 0,
total: 1_160_300,
},
});
const main = usageSummary({
totalTokens: {
input: 1_000_000,
output: 60_000,
cacheMiss: 50_000,
cacheRead: 950_000,
cacheWrite: 0,
reasoning: 0,
total: 1_060_000,
},
});

const blendedRate = deriveInspectorOverviewModel(undefined, blended).cacheHitRate;
const refined = deriveInspectorOverviewModel(undefined, {
...blended,
mainSummary: main,
}).cacheHitRate;

assert.ok(blendedRate);
assert.equal(Math.round(blendedRate * 100), 86);
assert.ok(refined);
assert.equal(Math.round(refined * 100), 95);
});

test('shows no cache rate when the main-only read failed rather than blending', () => {
// A failed narrow query leaves the blended totals in place. Reporting the
// blended 86% as the main loop's rate is the same misreading the main
// summary exists to prevent (#5691 review): hide the rate instead.
const { cacheHitRate } = deriveInspectorOverviewModel(undefined, {
...usageSummary({
totalTokens: {
input: 1_100_000,
output: 60_300,
cacheMiss: 150_000,
cacheRead: 950_000,
cacheWrite: 0,
reasoning: 0,
total: 1_160_300,
},
}),
mainSummaryUnavailable: true,
});

assert.equal(cacheHitRate, undefined);
});

test('marks the overview when the injected preload IPC read of the main loop fails', async () => {
// The model-level test above pins the hiding rule; this one pins the
// preload wiring that feeds it. A failed main-only IPC call — the injected
// preload failure path — must mark the blended summary rather than drop
// the overview or present the blend as the main loop's (#5691 review).
const blended: DesktopSessionUsageSummary = {
...usageSummary(),
provenance: fullCoverageProvenance(),
};
const calls: Array<Record<string, unknown>> = [];
const invoke: UsageSummaryInvoke = async (_channel, _scope, args) => {
calls.push(args);
return (args as { callKinds?: readonly string[] }).callKinds
? { ok: false, error: { code: 'persistence_failed', message: 'usage read failed' } }
: { ok: true, data: blended };
};

const outcome = await loadSessionUsageSummaryVia(invoke, {
scope: { profileId: 'profile-1' },
sessionId: 'session-1',
});

assert.equal(outcome.ok, true);
if (!outcome.ok) return;
assert.equal(outcome.data.mainSummary, undefined);
assert.equal(outcome.data.mainSummaryUnavailable, true);

// A crashed secondary invoke (rejected promise, not an ok:false result)
// must degrade the same way instead of failing the overview (#5691 review).
const crashingInvoke: UsageSummaryInvoke = async (_channel, _scope, args) => {
if ((args as { callKinds?: readonly string[] }).callKinds) {
throw new Error('IPC transport died');
}
return { ok: true, data: usageSummary() } as Awaited<ReturnType<UsageSummaryInvoke>>;
};
const degraded = await loadSessionUsageSummaryVia(crashingInvoke, {
scope: { profileId: 'profile-1' },
sessionId: 'session-1',
});
assert.equal(degraded.ok, true);
if (!degraded.ok) return;
assert.equal(degraded.data.mainSummaryUnavailable, true);
// The second read asked for exactly the agent loop's own calls.
assert.deepEqual(
calls.map((args) => (args as { callKinds?: readonly string[] }).callKinds ?? null),
[null, ['main']],
);
});

test('attaches the main summary when both injected preload reads succeed', async () => {
const invoke: UsageSummaryInvoke = async (_channel, _scope, args) => ({
ok: true,
data: (args as { callKinds?: readonly string[] }).callKinds
? { ...usageSummary(), provenance: fullCoverageProvenance(), cacheHitRequests: 2 }
: { ...usageSummary({ cacheHitRequests: 3 }), provenance: fullCoverageProvenance() },
});

const outcome = await loadSessionUsageSummaryVia(invoke, {
scope: { profileId: 'profile-1' },
sessionId: 'session-1',
});

assert.equal(outcome.ok, true);
if (!outcome.ok) return;
assert.equal(outcome.data.mainSummaryUnavailable, undefined);
assert.equal(outcome.data.mainSummary?.cacheHitRequests, 2);
});
8 changes: 8 additions & 0 deletions apps/desktop/src/preload/bridge-contract.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -809,6 +809,14 @@ export interface DesktopSessionTracePage {

export interface DesktopSessionUsageSummary extends UsageSummaryV2 {
readonly provenance: UsageProvenance;
/**
* The same Session scoped to the agent loop's own calls (`callKinds:
* ['main']`), when the narrower read succeeded. The overview's cache rate
* reads this: auxiliary prompts have their own cache prefix (#5691).
*/
readonly mainSummary?: DesktopSessionUsageSummary;
/** The narrower read failed; the blended rate must not stand in for it. */
readonly mainSummaryUnavailable?: boolean;
}

export interface MakaBridge {
Expand Down
8 changes: 2 additions & 6 deletions apps/desktop/src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ import type {
AppIconSelectResult,
} from './bridge-contract.js';
import type { ExternalSessionImportIpcResult } from './external-session-import-result.js';
import { loadSessionUsageSummaryVia } from './usage-summary.js';
import type { RuntimeHostObservationIpcResult } from '../shared/runtime-host-observation-ipc.js';
import {
projectDesktopExternalSessionCatalogItem,
Expand Down Expand Up @@ -1362,12 +1363,7 @@ async function loadSessionTracePage(
async function loadSessionUsageSummary(
sessionId: string,
): Promise<Result<DesktopSessionUsageSummary>> {
const session = await runtimeHostSessionRef(sessionId);
return invokeWhenReady(
'usage:summary',
session.scope,
{ range: 'all', sessionId: session.sessionId },
) as Promise<Result<DesktopSessionUsageSummary>>;
return loadSessionUsageSummaryVia(invokeWhenReady, await runtimeHostSessionRef(sessionId));
}

async function updateDailyReviewConfig(
Expand Down
58 changes: 58 additions & 0 deletions apps/desktop/src/preload/usage-summary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import type { Result } from '@maka/core/result';
import type { DesktopSessionUsageSummary } from './bridge-contract.js';

/** The narrow invoke surface the usage overview needs; injectable for tests. */
export type UsageSummaryInvoke = (
channel: 'usage:summary',
scope: unknown,
args: Record<string, unknown>,
) => Promise<Result<DesktopSessionUsageSummary>>;

/**
* The overview reads the Session's whole metered spend and, separately, the
* agent loop's own calls: auxiliary prompts do not share the main loop's
* cached prefix, so a blended rate under-reports it (#5691).
*
* The main-only read refines the cache rate; losing it must not lose the
* overview — but it must also not pass the blended rate off as the main
* loop's, so the failure is marked and the rate hides itself (#5691).
*/
export async function loadSessionUsageSummaryVia(
invoke: UsageSummaryInvoke,
session: { readonly scope: unknown; readonly sessionId: string },
): Promise<Result<DesktopSessionUsageSummary>> {
const summaryQuery = { range: 'all' as const, sessionId: session.sessionId };
// A rejected secondary invoke (transport/IPC crash rather than an
// operation-level failure) must not take the whole overview down with it —
// it marks the summary unavailable like any other main-read failure (#5691
// review).
const [summary, main] = (await Promise.all([
invoke('usage:summary', session.scope, summaryQuery),
invoke('usage:summary', session.scope, { ...summaryQuery, callKinds: ['main'] }).catch(
() => ({ ok: false, error: { code: 'persistence_failed', message: 'usage read failed' } }),
),
])) as [Result<DesktopSessionUsageSummary>, Result<DesktopSessionUsageSummary>];
Comment thread
ggbdpq marked this conversation as resolved.
if (!summary.ok) return summary;
return main.ok
? { ...summary, data: { ...summary.data, mainSummary: main.data } }
: { ...summary, data: { ...summary.data, mainSummaryUnavailable: true } };
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,13 @@ import type {
import type { UsageSummaryV2 } from '@maka/core/usage-stats/types';
import type { UsageProvenance } from '@maka/core/usage-ledger-merge';

type SessionUsageSummary = UsageSummaryV2 & { readonly provenance?: UsageProvenance };
type SessionUsageSummary = UsageSummaryV2 & {
readonly provenance?: UsageProvenance;
/** The agent loop's own calls, when the narrower read succeeded (#5691). */
readonly mainSummary?: SessionUsageSummary;
/** The narrower read failed; the blended rate must not stand in for it. */
readonly mainSummaryUnavailable?: boolean;
};

/**
* Overview view model for the Inspector panel's summary sections.
Expand Down Expand Up @@ -243,15 +249,24 @@ export function deriveInspectorOverviewModel(
}

function usageCacheHitRate(usage: SessionUsageSummary | undefined): number | undefined {
if (!usage || usage.totalTokens.input === 0) return undefined;
// Auxiliary calls keep their own prompt prefix, so blending them into the
// rate reports the main loop's caching as worse than it is (#5691). The
// main-only summary is the rate's input. When that narrower read failed,
// show no rate at all: the blended number is about a different set of calls
// and presenting it as the main loop's is the same lie in weaker type. The
// fallback to the blended summary only serves summaries that never asked
// the narrower question.
if (usage?.mainSummaryUnavailable && !usage.mainSummary) return undefined;
const main = usage?.mainSummary ?? usage;
if (!main || main.totalTokens.input === 0) return undefined;
if (
usage.provenance &&
(usage.provenance.coverage.usagePartialAttempts > 0 ||
usage.provenance.coverage.usageMissingAttempts > 0)
main.provenance &&
(main.provenance.coverage.usagePartialAttempts > 0 ||
main.provenance.coverage.usageMissingAttempts > 0)
) {
return undefined;
}
return usage.totalTokens.cacheRead / usage.totalTokens.input;
return main.totalTokens.cacheRead / main.totalTokens.input;
}

/**
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/usage-stats/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ export interface UsageQuery {
modelId?: string;
toolName?: string;
status?: 'success' | 'error' | 'aborted' | 'all';
/**
* Keep only calls of these kinds. Unset means every kind, which is what a
* Session's headline cost has always reported; `['main']` is how a consumer
* reads the agent loop's own spend and cache behavior without the auxiliary
* calls recorded alongside it (#5691).
*/
callKinds?: readonly ModelCallKind[];
}

export interface UsageSummaryV2 {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"epoch": 200,
"epoch": 208,
"files": [
"packages/runtime-host/src/protocol/operations.ts",
"packages/runtime-host/src/protocol/turn.ts"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"epoch": 200,
"epoch": 208,
"files": ["packages/runtime-host/src/protocol/turn.ts"],
"reason": "Reorganizes the local construction of already-supported optional Turn snapshot fields; it changes neither the wire keys nor accepted values."
}
4 changes: 3 additions & 1 deletion packages/runtime-host/src/protocol/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,9 @@ 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 = 206 as const;
export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 208 as const;
// 208: Usage queries filter by model call kind. Epoch-207 peers reject the
// filter or ignore it.
// 206: `storage.retention.query` and `storage.retention.set` read and change the
// opt-in retention for archived tasks. An epoch-205 Client could not show or
// turn off a Host that deletes archived tasks on its own, and an epoch-205 Host
Expand Down
12 changes: 12 additions & 0 deletions packages/runtime-host/src/protocol/usage-pricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ const LLM_USAGE_QUERY_FIELDS = new Set([
'providerId',
'modelId',
'status',
'callKinds',
]);
const TOOL_USAGE_QUERY_FIELDS = new Set(['range', 'toolName', 'status']);
const USAGE_BUCKET_FIELDS = new Set([
Expand Down Expand Up @@ -709,9 +710,20 @@ function decodeLlmUsageQuery(value: unknown): LlmUsageQuery {
...optionalQueryText(query, 'providerId'),
...optionalQueryText(query, 'modelId'),
...(query.status === undefined ? {} : { status: decodeUsageStatus(query.status) }),
...(query.callKinds === undefined ? {} : { callKinds: decodeCallKinds(query.callKinds) }),
};
}

function decodeCallKinds(value: unknown): readonly ModelCallKind[] {
if (!Array.isArray(value)) throw invalidProtocolFrame('Invalid usage query callKinds');
return value.map((entry) => {
if (typeof entry !== 'string' || !MODEL_CALL_KINDS.includes(entry as ModelCallKind)) {
throw invalidProtocolFrame('Invalid usage query callKind');
}
return entry as ModelCallKind;
});
}

function decodeToolUsageQuery(value: unknown): ToolUsageQuery {
const query = requireRecord(value, 'tool usage query');
assertAllowedKeys(query, TOOL_USAGE_QUERY_FIELDS, 'tool usage query');
Expand Down
Loading