Skip to content

Commit 3ce9980

Browse files
committed
test(desktop): exercise the injected preload failure behind the usage overview
The review of 7a51a1c observed that the failure-path coverage for the main-only usage read stopped at the overview model: nothing pinned the preload wiring that feeds it. Extract the blended+main double query into `preload/usage-summary.ts` with the invoke surface injectable, keep `preload.ts` on the real `invokeWhenReady`, and pin the injected failure: a failed main-only IPC call marks `mainSummaryUnavailable` on the blended summary (and the narrow read asks for exactly `callKinds: ['main']`), while both-succeed attaches `mainSummary`. Generated-by: GLM-5.3-Flash (ZCode)
1 parent 14de3ce commit 3ce9980

3 files changed

Lines changed: 127 additions & 19 deletions

File tree

‎apps/desktop/src/main/__tests__/session-inspector-usage-stats.test.ts‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ import {
2626
usageRingArcs,
2727
} from '../../renderer/features/workbar/testing.js';
2828
import type { UsageSummaryV2 } from '@maka/core/usage-stats/types';
29+
import type { DesktopSessionUsageSummary } from '../../preload/bridge-contract.js';
30+
import {
31+
loadSessionUsageSummaryVia,
32+
type UsageSummaryInvoke,
33+
} from '../../preload/usage-summary.js';
2934

3035
function usageSummary(overrides: Partial<UsageSummaryV2> = {}): UsageSummaryV2 {
3136
return {
@@ -49,6 +54,22 @@ function usageSummary(overrides: Partial<UsageSummaryV2> = {}): UsageSummaryV2 {
4954
};
5055
}
5156

57+
function fullCoverageProvenance(): DesktopSessionUsageSummary['provenance'] {
58+
return {
59+
coverage: {
60+
attempts: 3,
61+
pricedAttempts: 3,
62+
unpricedAttempts: 0,
63+
usageReportedAttempts: 3,
64+
usagePartialAttempts: 0,
65+
usageMissingAttempts: 0,
66+
},
67+
legacyRecords: 0,
68+
unreadableRecords: 0,
69+
pendingRepairs: 0,
70+
};
71+
}
72+
5273
test('splits the session metered tokens the way a bill reads', () => {
5374
const { tokenUsage } = deriveInspectorOverviewModel(
5475
undefined,
@@ -405,3 +426,55 @@ test('shows no cache rate when the main-only read failed rather than blending',
405426

406427
assert.equal(cacheHitRate, undefined);
407428
});
429+
430+
test('marks the overview when the injected preload IPC read of the main loop fails', async () => {
431+
// The model-level test above pins the hiding rule; this one pins the
432+
// preload wiring that feeds it. A failed main-only IPC call — the injected
433+
// preload failure path — must mark the blended summary rather than drop
434+
// the overview or present the blend as the main loop's (#5691 review).
435+
const blended: DesktopSessionUsageSummary = {
436+
...usageSummary(),
437+
provenance: fullCoverageProvenance(),
438+
};
439+
const calls: Array<Record<string, unknown>> = [];
440+
const invoke: UsageSummaryInvoke = async (_channel, _scope, args) => {
441+
calls.push(args);
442+
return (args as { callKinds?: readonly string[] }).callKinds
443+
? { ok: false, error: { code: 'persistence_failed', message: 'usage read failed' } }
444+
: { ok: true, data: blended };
445+
};
446+
447+
const outcome = await loadSessionUsageSummaryVia(invoke, {
448+
scope: { profileId: 'profile-1' },
449+
sessionId: 'session-1',
450+
});
451+
452+
assert.equal(outcome.ok, true);
453+
if (!outcome.ok) return;
454+
assert.equal(outcome.data.mainSummary, undefined);
455+
assert.equal(outcome.data.mainSummaryUnavailable, true);
456+
// The second read asked for exactly the agent loop's own calls.
457+
assert.deepEqual(
458+
calls.map((args) => (args as { callKinds?: readonly string[] }).callKinds ?? null),
459+
[null, ['main']],
460+
);
461+
});
462+
463+
test('attaches the main summary when both injected preload reads succeed', async () => {
464+
const invoke: UsageSummaryInvoke = async (_channel, _scope, args) => ({
465+
ok: true,
466+
data: (args as { callKinds?: readonly string[] }).callKinds
467+
? { ...usageSummary(), provenance: fullCoverageProvenance(), cacheHitRequests: 2 }
468+
: { ...usageSummary({ cacheHitRequests: 3 }), provenance: fullCoverageProvenance() },
469+
});
470+
471+
const outcome = await loadSessionUsageSummaryVia(invoke, {
472+
scope: { profileId: 'profile-1' },
473+
sessionId: 'session-1',
474+
});
475+
476+
assert.equal(outcome.ok, true);
477+
if (!outcome.ok) return;
478+
assert.equal(outcome.data.mainSummaryUnavailable, undefined);
479+
assert.equal(outcome.data.mainSummary?.cacheHitRequests, 2);
480+
});

‎apps/desktop/src/preload/preload.ts‎

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ import type {
9191
AppIconSelectResult,
9292
} from './bridge-contract.js';
9393
import type { ExternalSessionImportIpcResult } from './external-session-import-result.js';
94+
import { loadSessionUsageSummaryVia } from './usage-summary.js';
9495
import type { RuntimeHostObservationIpcResult } from '../shared/runtime-host-observation-ipc.js';
9596
import {
9697
projectDesktopExternalSessionCatalogItem,
@@ -1315,25 +1316,7 @@ async function loadSessionTracePage(
13151316
async function loadSessionUsageSummary(
13161317
sessionId: string,
13171318
): Promise<Result<DesktopSessionUsageSummary>> {
1318-
const session = await runtimeHostSessionRef(sessionId);
1319-
const summaryQuery = { range: 'all' as const, sessionId: session.sessionId };
1320-
// The overview reads the Session's whole metered spend and, separately, the
1321-
// agent loop's own calls: auxiliary prompts do not share the main loop's
1322-
// cached prefix, so a blended rate under-reports it (#5691).
1323-
const [summary, main] = (await Promise.all([
1324-
invokeWhenReady('usage:summary', session.scope, summaryQuery),
1325-
invokeWhenReady('usage:summary', session.scope, {
1326-
...summaryQuery,
1327-
callKinds: ['main'],
1328-
}),
1329-
])) as [Result<DesktopSessionUsageSummary>, Result<DesktopSessionUsageSummary>];
1330-
if (!summary.ok) return summary;
1331-
// The main-only read refines the cache rate; losing it must not lose the
1332-
// overview — but it must also not pass the blended rate off as the main
1333-
// loop's, so the failure is marked and the rate hides itself (#5691).
1334-
return main.ok
1335-
? { ...summary, data: { ...summary.data, mainSummary: main.data } }
1336-
: { ...summary, data: { ...summary.data, mainSummaryUnavailable: true } };
1319+
return loadSessionUsageSummaryVia(invokeWhenReady, await runtimeHostSessionRef(sessionId));
13371320
}
13381321

13391322
async function updateDailyReviewConfig(
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import type { Result } from '@maka/core/result';
21+
import type { DesktopSessionUsageSummary } from './bridge-contract.js';
22+
23+
/** The narrow invoke surface the usage overview needs; injectable for tests. */
24+
export type UsageSummaryInvoke = (
25+
channel: 'usage:summary',
26+
scope: unknown,
27+
args: Record<string, unknown>,
28+
) => Promise<Result<DesktopSessionUsageSummary>>;
29+
30+
/**
31+
* The overview reads the Session's whole metered spend and, separately, the
32+
* agent loop's own calls: auxiliary prompts do not share the main loop's
33+
* cached prefix, so a blended rate under-reports it (#5691).
34+
*
35+
* The main-only read refines the cache rate; losing it must not lose the
36+
* overview — but it must also not pass the blended rate off as the main
37+
* loop's, so the failure is marked and the rate hides itself (#5691).
38+
*/
39+
export async function loadSessionUsageSummaryVia(
40+
invoke: UsageSummaryInvoke,
41+
session: { readonly scope: unknown; readonly sessionId: string },
42+
): Promise<Result<DesktopSessionUsageSummary>> {
43+
const summaryQuery = { range: 'all' as const, sessionId: session.sessionId };
44+
const [summary, main] = (await Promise.all([
45+
invoke('usage:summary', session.scope, summaryQuery),
46+
invoke('usage:summary', session.scope, { ...summaryQuery, callKinds: ['main'] }),
47+
])) as [Result<DesktopSessionUsageSummary>, Result<DesktopSessionUsageSummary>];
48+
if (!summary.ok) return summary;
49+
return main.ok
50+
? { ...summary, data: { ...summary.data, mainSummary: main.data } }
51+
: { ...summary, data: { ...summary.data, mainSummaryUnavailable: true } };
52+
}

0 commit comments

Comments
 (0)