Skip to content
Merged
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
29 changes: 28 additions & 1 deletion src/daemon/__tests__/request-router-cost.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ vi.mock('../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {})

import { dispatchCommand } from '../../core/dispatch.ts';
import { createRequestHandler } from '../request-router.ts';
import { emitDiagnostic } from '../../utils/diagnostics.ts';
import type { DaemonRequest, SessionState } from '../types.ts';
import { LeaseRegistry } from '../lease-registry.ts';
import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts';
Expand Down Expand Up @@ -110,9 +111,12 @@ test('(b) flag-on additive-only: cost block is the ONLY delta vs flag-off', asyn
expect(respFlagOn.ok).toBe(true);
if (!respFlagOff.ok || !respFlagOn.ok) return;

// The cost block carries BOTH wallClockMs and runnerRoundTrips, both numbers
// ≥ 0. A request that never touches the iOS runner reports 0 — honest.
const cost = respFlagOn.data?.cost;
expect(cost).toMatchObject({
wallClockMs: expect.any(Number),
runnerRoundTrips: 0,
});
expect(cost?.wallClockMs).toBeGreaterThanOrEqual(0);
// This payload has no node tree, so nodeCount is omitted entirely.
Expand All @@ -123,7 +127,30 @@ test('(b) flag-on additive-only: cost block is the ONLY delta vs flag-off', asyn
expect(respFlagOn.data).toEqual(respFlagOff.data);
});

test('(c) nodeCount reports the node-tree size whenever data carries a nodes array, additive-only', async () => {
test('(c) runnerRoundTrips counts real iOS-runner round-trip diagnostics in scope', async () => {
const { sessionStore, handler } = makeHandler();
sessionStore.set('cost-session', makeIosSession('cost-session'));

// The mocked dispatch runs inside the request's diagnostics scope, so emitting
// here is equivalent to the runner-session emitting these phases per round-trip.
mockDispatch.mockImplementation(async () => {
emitDiagnostic({ phase: 'ios_runner_readiness_preflight' }); // real round-trip
emitDiagnostic({ phase: 'ios_runner_command_send' }); // real round-trip
emitDiagnostic({ phase: 'ios_runner_command_send' }); // real round-trip
emitDiagnostic({ level: 'debug', phase: 'ios_runner_readiness_preflight_skipped' }); // NOT
emitDiagnostic({ phase: 'some_other_phase' }); // NOT
return { ...REPRESENTATIVE_PAYLOAD };
});

const resp = await handler(baseRequest({ meta: { includeCost: true } }));
expect(resp.ok).toBe(true);
if (!resp.ok) return;
// 1 preflight + 2 command_send = 3; the _skipped marker and unrelated phases
// are excluded.
expect(resp.data?.cost?.runnerRoundTrips).toBe(3);
});

test('(c2) nodeCount reports the node-tree size whenever data carries a nodes array, additive-only', async () => {
const { sessionStore, handler } = makeHandler();
sessionStore.set('cost-session', makeIosSession('cost-session'));

Expand Down
15 changes: 15 additions & 0 deletions src/daemon/request-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
withRequestPlatformProviderScope,
} from './request-platform-providers.ts';
import {
countDiagnosticEventsByPhase,
emitDiagnostic,
flushDiagnosticsToSessionFile,
getDiagnosticsMeta,
Expand Down Expand Up @@ -344,12 +345,26 @@ function applyResponseLevelView(
return view ? { ok: true, data: view(response.data ?? {}, level) } : response;
}

// Diagnostic phases emitted once per real iOS-runner round-trip. `..._command_send`
// is the command itself; `..._readiness_preflight` is the pre-command uptime probe
// (a real network round-trip). The `..._skipped` / `..._recovered` markers do NOT
// hit the runner and are intentionally excluded.
const RUNNER_ROUND_TRIP_PHASES = [
'ios_runner_command_send',
'ios_runner_readiness_preflight',
] as const;

function buildResponseCost(
originalData: DaemonResponseData | undefined,
startedAt: number,
): ResponseCost {
const cost: ResponseCost = {
wallClockMs: Date.now() - startedAt,
// Counts this request's real runner round-trips from the flush-surviving
// diagnostics phase tally. Reads 0 when no runner was hit (e.g. a no-op or a
// command served entirely from the daemon). Must run inside the request's
// diagnostics scope (see `applyAgentCostGrafts` call site).
runnerRoundTrips: countDiagnosticEventsByPhase(RUNNER_ROUND_TRIP_PHASES),
};
// nodeCount reads the ORIGINAL node tree (the digest view may have already
// collapsed `data.nodes`), so the count stays accurate.
Expand Down
4 changes: 4 additions & 0 deletions src/kernel/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ export type DaemonArtifact = {

export type ResponseCost = {
wallClockMs: number;
// Number of real iOS-runner round-trips made while serving the request (the
// `ios_runner_command_send` + `ios_runner_readiness_preflight` diagnostic
// phases). Always present when cost is included; 0 when no runner was hit.
runnerRoundTrips: number;
// Number of UI/accessibility nodes in the response, when the command returns a
// node tree (e.g. snapshot). Absent for commands that produce no nodes, so an
// agent can size a snapshot before re-fetching at a different depth/scope.
Expand Down
16 changes: 16 additions & 0 deletions src/utils/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,22 @@ export function getDiagnosticsMeta(): {
};
}

/**
* Sum the number of diagnostic events emitted in the current scope whose phase
* is one of `phases`. Backed by the flush-surviving `phaseCounts` tally, so it
* stays accurate for the whole request even under `--debug` (where `events` is
* streamed out and reset). Returns 0 when called outside a diagnostics scope.
*/
export function countDiagnosticEventsByPhase(phases: readonly string[]): number {
const scope = diagnosticsStorage.getStore();
if (!scope) return 0;
let total = 0;
for (const phase of phases) {
total += scope.phaseCounts.get(phase) ?? 0;
}
return total;
}

export function emitDiagnostic(event: {
level?: DiagnosticLevel;
phase: string;
Expand Down
Loading