Skip to content

Commit 43d0a63

Browse files
authored
feat: leveled response views + --level knob with snapshot digest — Phase 4 (#942)
* feat: leveled response views + --level knob, with a snapshot digest — Phase 4 Add the agent-cost leveled-response system: a responseLevel knob (digest | default | full) plumbed end to end behind a global --level flag (mirroring --cost), and a per-command ResponseView registry applied in the router on the success path. - contracts: RESPONSE_LEVELS/ResponseLevel + meta.responseLevel + boundary schema whitelist. Plumbing mirrors --cost: cli-flags FlagDefinition + GLOBAL_FLAG_KEYS, AgentDeviceClientConfig + overrides, buildClientConfig, buildMeta. ResponseLevel exported from the public root. - src/daemon/response-views.ts: the ResponseView registry. Seeds the snapshot digest — the full node tree (the dominant token sink) collapses to { nodeCount, refs: first 12 hittable/non-occluded refs with labels } plus the cheap top-level signals (truncated/visibility/snapshotQuality). full returns today's shape (nothing richer is computed yet). - router graft (applyResponseLevelView + applyAgentCostGrafts): composes with the existing cost block. With responseLevel default (or unset) AND no registered view AND no --cost, the original response is returned UNCHANGED — byte-identical to today (Maestro .ad recompare safe). cost.nodeCount reads the original node tree so it stays accurate even after a digest. Tests: snapshot view unit test (digest filters hittable/occluded, drops the tree, keeps cheap signals; default/full passthrough); router graft test via an injected view (default identity byte-identical, digest applies, full passthrough, digest+cost composition, unregistered-command passthrough, boundary parse). Verified: tsc, oxfmt + oxlint --deny-warnings, fallow audit clean, rslib build, Layering Guard empty, 1106 daemon/contracts/client tests pass (incl. the existing cost/typed-error grafts after the restructure). * fix: repoint MCP output-schemas import to kernel/device (rebase fixup) The kernel move (#940) deleted src/utils/device.ts; #941's command-output-schemas.ts (merged after #940's codemod ran) still imported the old path. Same one-line fix as #943; de-dups once that lands. * fix: re-classify responseLevel flag in integration-progress model The --level/responseLevel flag is a diagnostics/output flag (not device- observable), classified in the exclusion bucket alongside --cost. (Lost in an earlier rebase; re-applying.)
1 parent bb45967 commit 43d0a63

11 files changed

Lines changed: 326 additions & 26 deletions

scripts/integration-progress-model.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ function summarizeProviderScenarioFlagExclusions() {
222222
'version',
223223
'verbose',
224224
'cost',
225+
'responseLevel',
225226
],
226227
},
227228
{

src/cli.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,7 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
253253
cwd: process.cwd(),
254254
debug: debugOutputEnabled,
255255
cost: currentFlags.cost,
256+
responseLevel: currentFlags.responseLevel,
256257
});
257258
let parsedBatchSteps: BatchStep[] | undefined;
258259
if (command === 'batch') {

src/client-normalizers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,7 @@ export function buildMeta(options: InternalRequestOptions): DaemonRequest['meta'
352352
sessionExplicit: options.session !== undefined,
353353
debug: options.debug,
354354
includeCost: options.cost,
355+
responseLevel: options.responseLevel,
355356
lockPolicy: options.lockPolicy,
356357
lockPlatform: options.lockPlatform,
357358
...leaseScopeToRequestMeta(leaseScope),

src/client-types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
DaemonResponse,
99
LeaseBackend,
1010
NetworkIncludeMode,
11+
ResponseLevel,
1112
SessionIsolationMode,
1213
SessionRuntimeHints,
1314
} from './contracts.ts';
@@ -79,6 +80,7 @@ export type AgentDeviceClientConfig = RemoteConnectionProfileFields & {
7980
cwd?: string;
8081
debug?: boolean;
8182
cost?: boolean;
83+
responseLevel?: ResponseLevel;
8284
iosXctestrunFile?: string;
8385
iosXctestDerivedDataPath?: string;
8486
iosXctestEnvDir?: string;
@@ -106,6 +108,7 @@ export type AgentDeviceRequestOverrides = Pick<
106108
| 'cwd'
107109
| 'debug'
108110
| 'cost'
111+
| 'responseLevel'
109112
| 'iosXctestrunFile'
110113
| 'iosXctestDerivedDataPath'
111114
| 'iosXctestEnvDir'

src/contracts.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,17 @@ export type SessionIsolationMode = (typeof SESSION_ISOLATION_MODES)[number];
5858
export const NETWORK_INCLUDE_MODES = ['summary', 'headers', 'body', 'all'] as const;
5959
export type NetworkIncludeMode = (typeof NETWORK_INCLUDE_MODES)[number];
6060

61+
// Agent-cost leveled response views (Phase 4). `default` == today's exact wire
62+
// shape (Maestro `.ad` recompare safe); `digest` is a token-cheap view; `full`
63+
// is the richest view (== default until a command surfaces extra detail).
64+
export const RESPONSE_LEVELS = ['digest', 'default', 'full'] as const;
65+
export type ResponseLevel = (typeof RESPONSE_LEVELS)[number];
66+
6167
export type DaemonRequestMeta = {
6268
requestId?: string;
6369
debug?: boolean;
6470
includeCost?: boolean;
71+
responseLevel?: ResponseLevel;
6572
cwd?: string;
6673
sessionExplicit?: boolean;
6774
tenantId?: string;
@@ -447,6 +454,7 @@ export const daemonCommandRequestSchema = schema<DaemonRequest>((input, path) =>
447454
requestId: optionalString(meta, 'requestId', `${path}.meta`),
448455
debug: optionalBoolean(meta, 'debug', `${path}.meta`),
449456
includeCost: optionalBoolean(meta, 'includeCost', `${path}.meta`),
457+
responseLevel: optionalEnum(meta, 'responseLevel', RESPONSE_LEVELS, `${path}.meta`),
450458
cwd: optionalString(meta, 'cwd', `${path}.meta`),
451459
sessionExplicit: optionalBoolean(meta, 'sessionExplicit', `${path}.meta`),
452460
tenantId: optionalString(meta, 'tenantId', `${path}.meta`),
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { test, expect, vi, beforeEach } from 'vitest';
2+
import os from 'node:os';
3+
import path from 'node:path';
4+
5+
vi.mock('../../core/dispatch.ts', async (importOriginal) => {
6+
const actual = await importOriginal<typeof import('../../core/dispatch.ts')>();
7+
return { ...actual, dispatchCommand: vi.fn(async () => ({})) };
8+
});
9+
10+
vi.mock('../../platforms/ios/runner-client.ts', async (importOriginal) => {
11+
const actual = await importOriginal<typeof import('../../platforms/ios/runner-client.ts')>();
12+
return { ...actual, stopIosRunnerSession: vi.fn(async () => {}) };
13+
});
14+
15+
vi.mock('../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) }));
16+
17+
// Register a test view on a command that flows through the (mocked) generic
18+
// dispatch path, so the router graft mechanics can be exercised end to end
19+
// without the real snapshot handler (the actual snapshot view is unit-tested in
20+
// response-views.test.ts).
21+
vi.mock('../response-views.ts', async (importOriginal) => {
22+
const actual = await importOriginal<typeof import('../response-views.ts')>();
23+
return {
24+
...actual,
25+
RESPONSE_VIEWS: {
26+
...actual.RESPONSE_VIEWS,
27+
home: (data: Record<string, unknown>, level: string) =>
28+
level === 'digest' ? { homeDigest: true, hadItems: Array.isArray(data.items) } : data,
29+
},
30+
};
31+
});
32+
33+
import { dispatchCommand } from '../../core/dispatch.ts';
34+
import { createRequestHandler } from '../request-router.ts';
35+
import type { DaemonRequest, SessionState } from '../types.ts';
36+
import { LeaseRegistry } from '../lease-registry.ts';
37+
import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts';
38+
import { daemonCommandRequestSchema } from '../../contracts.ts';
39+
40+
const mockDispatch = vi.mocked(dispatchCommand);
41+
42+
const REPRESENTATIVE_PAYLOAD = { message: 'home-ok', items: [1, 2, 3] } as const;
43+
44+
function makeIosSession(name: string): SessionState {
45+
return {
46+
name,
47+
createdAt: 1_700_000_000_000,
48+
actions: [],
49+
device: {
50+
platform: 'ios',
51+
target: 'mobile',
52+
id: 'SIM-001',
53+
name: 'iPhone 16',
54+
kind: 'simulator',
55+
booted: true,
56+
simulatorSetPath: '/tmp/tenant-a/set',
57+
},
58+
};
59+
}
60+
61+
function makeHandler() {
62+
const sessionStore = makeSessionStore('agent-device-router-level-');
63+
sessionStore.set('level-session', makeIosSession('level-session'));
64+
return {
65+
sessionStore,
66+
handler: createRequestHandler({
67+
logPath: path.join(os.tmpdir(), 'daemon.log'),
68+
token: 'test-token',
69+
sessionStore,
70+
leaseRegistry: new LeaseRegistry(),
71+
trackDownloadableArtifact: () => 'artifact-id',
72+
}),
73+
};
74+
}
75+
76+
function request(command: string, overrides: Partial<DaemonRequest> = {}): DaemonRequest {
77+
return {
78+
token: 'test-token',
79+
session: 'level-session',
80+
command,
81+
positionals: [],
82+
flags: {},
83+
...overrides,
84+
};
85+
}
86+
87+
beforeEach(() => {
88+
mockDispatch.mockReset();
89+
mockDispatch.mockImplementation(async () => ({ ...REPRESENTATIVE_PAYLOAD }));
90+
});
91+
92+
test('(a) default identity: responseLevel absent === default === no meta, byte-identical', async () => {
93+
const { handler } = makeHandler();
94+
const noMeta = await handler(request('home'));
95+
const emptyMeta = await handler(request('home', { meta: {} }));
96+
const explicitDefault = await handler(request('home', { meta: { responseLevel: 'default' } }));
97+
98+
expect(JSON.stringify(noMeta)).toBe(JSON.stringify(emptyMeta));
99+
expect(JSON.stringify(noMeta)).toBe(JSON.stringify(explicitDefault));
100+
if (noMeta.ok) expect(noMeta.data).toEqual(REPRESENTATIVE_PAYLOAD);
101+
});
102+
103+
test('(b) digest applies the registered view, dropping the full payload', async () => {
104+
const { handler } = makeHandler();
105+
const resp = await handler(request('home', { meta: { responseLevel: 'digest' } }));
106+
expect(resp.ok).toBe(true);
107+
if (!resp.ok) return;
108+
expect(resp.data).toEqual({ homeDigest: true, hadItems: true });
109+
expect('message' in (resp.data ?? {})).toBe(false);
110+
});
111+
112+
test('(c) full returns today’s shape (view passthrough) — byte-identical to default', async () => {
113+
const { handler } = makeHandler();
114+
const full = await handler(request('home', { meta: { responseLevel: 'full' } }));
115+
const def = await handler(request('home', { meta: { responseLevel: 'default' } }));
116+
expect(JSON.stringify(full)).toBe(JSON.stringify(def));
117+
});
118+
119+
test('(d) digest composes with --cost: viewed data plus an additive cost block', async () => {
120+
const { handler } = makeHandler();
121+
const resp = await handler(
122+
request('home', { meta: { responseLevel: 'digest', includeCost: true } }),
123+
);
124+
expect(resp.ok).toBe(true);
125+
if (!resp.ok) return;
126+
expect(resp.data).toMatchObject({ homeDigest: true, hadItems: true });
127+
expect(typeof resp.data?.cost?.wallClockMs).toBe('number');
128+
expect(resp.data?.cost?.runnerRoundTrips).toBe(0);
129+
});
130+
131+
test('(e) digest on a command with no registered view is byte-identical to default', async () => {
132+
const { handler } = makeHandler();
133+
const digest = await handler(request('back', { meta: { responseLevel: 'digest' } }));
134+
const def = await handler(request('back', { meta: {} }));
135+
expect(JSON.stringify(digest)).toBe(JSON.stringify(def));
136+
if (digest.ok) expect(digest.data).toEqual(REPRESENTATIVE_PAYLOAD);
137+
});
138+
139+
test('(f) boundary survival: meta.responseLevel survives daemonCommandRequestSchema parsing', () => {
140+
const parsed = daemonCommandRequestSchema.parse({
141+
command: 'snapshot',
142+
positionals: [],
143+
meta: { responseLevel: 'digest' },
144+
});
145+
expect(parsed.meta?.responseLevel).toBe('digest');
146+
147+
const parsedOff = daemonCommandRequestSchema.parse({
148+
command: 'snapshot',
149+
positionals: [],
150+
meta: {},
151+
});
152+
expect(parsedOff.meta?.responseLevel).toBeUndefined();
153+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { test, expect } from 'vitest';
2+
import { RESPONSE_VIEWS } from '../response-views.ts';
3+
import type { DaemonResponseData } from '../types.ts';
4+
5+
const snapshotView = RESPONSE_VIEWS.snapshot;
6+
7+
const SNAPSHOT_DATA: DaemonResponseData = {
8+
nodes: [
9+
{ ref: 'e1', hittable: true, label: 'Login' },
10+
{ ref: 'e2', hittable: false, label: 'Heading' }, // not hittable → excluded
11+
{ ref: 'e3', hittable: true, interactionBlocked: 'covered', label: 'Hidden' }, // occluded → excluded
12+
{ ref: 'e4', hittable: true, value: 'from-value' }, // label falls back to value
13+
],
14+
truncated: false,
15+
visibility: { partial: false, visibleNodeCount: 4, totalNodeCount: 4, reasons: [] },
16+
snapshotQuality: { state: 'healthy', backend: 'tree' },
17+
appName: 'Demo', // a non-cheap field that the digest intentionally drops
18+
};
19+
20+
test('snapshot view is registered', () => {
21+
expect(typeof snapshotView).toBe('function');
22+
});
23+
24+
test('digest collapses the node tree to count + actionable refs + cheap signals', () => {
25+
const digest = snapshotView!(SNAPSHOT_DATA, 'digest');
26+
expect(digest).toEqual({
27+
nodeCount: 4,
28+
refs: [
29+
{ ref: 'e1', label: 'Login' },
30+
{ ref: 'e4', label: 'from-value' },
31+
],
32+
truncated: false,
33+
visibility: { partial: false, visibleNodeCount: 4, totalNodeCount: 4, reasons: [] },
34+
snapshotQuality: { state: 'healthy', backend: 'tree' },
35+
});
36+
// The full node tree (the token sink) and non-cheap fields are dropped.
37+
expect('nodes' in digest).toBe(false);
38+
expect('appName' in digest).toBe(false);
39+
});
40+
41+
test('default and full return today’s shape unchanged (same reference)', () => {
42+
expect(snapshotView!(SNAPSHOT_DATA, 'default')).toBe(SNAPSHOT_DATA);
43+
expect(snapshotView!(SNAPSHOT_DATA, 'full')).toBe(SNAPSHOT_DATA);
44+
});
45+
46+
test('digest tolerates missing/empty node trees', () => {
47+
const digest = snapshotView!({ truncated: true }, 'digest');
48+
expect(digest).toMatchObject({ nodeCount: 0, refs: [], truncated: true });
49+
});

src/daemon/request-router.ts

Lines changed: 47 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import { AppError, normalizeError, retriableForErrorCode } from '../kernel/error
66
import { supportedPlatformsForCommand } from '../core/capabilities.ts';
77
import { timingSafeStringEqual } from '../utils/timing-safe-equal.ts';
88
import type { DaemonError, ResponseCost } from '../contracts.ts';
9-
import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from './types.ts';
9+
import type { DaemonInvokeFn, DaemonRequest, DaemonResponse, DaemonResponseData } from './types.ts';
10+
import { RESPONSE_VIEWS } from './response-views.ts';
1011
import { SessionStore } from './session-store.ts';
1112
import { noActiveSessionError } from './handlers/response.ts';
1213
import {
@@ -116,23 +117,9 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn {
116117
if (!response.ok) {
117118
return { ok: false, error: enrichDaemonError(req.command, response.error) };
118119
}
119-
// Phase 4 (agent-cost) graft: cost is purely additive and opt-in. With
120-
// the flag off the serialized DaemonResponse is byte-identical to today
121-
// (Maestro `.ad` recompare diffs it). Mirrors the conditional
122-
// `registerDownloadableArtifacts` spread in request-finalization. Runs
123-
// inside the diagnostics scope so it can read this request's accumulated
124-
// runner-round-trip tally.
125-
if (!req.meta?.includeCost) return response;
126-
const cost: ResponseCost = {
127-
wallClockMs: Date.now() - start,
128-
runnerRoundTrips: countDiagnosticEventsByPhase(RUNNER_ROUND_TRIP_PHASES),
129-
};
130-
// Generic, command-agnostic: only the node-tree commands (snapshot) put a
131-
// `nodes` array on response.data, so this reads as a number there and is
132-
// omitted everywhere else.
133-
const nodes = response.data?.nodes;
134-
if (Array.isArray(nodes)) cost.nodeCount = nodes.length;
135-
return { ok: true, data: { ...(response.data ?? {}), cost } };
120+
// Phase 4 (agent-cost) grafts on the success path. Runs inside the
121+
// diagnostics scope so cost can read this request's runner-round-trip tally.
122+
return applyAgentCostGrafts(req, response, start);
136123
},
137124
);
138125
}
@@ -339,3 +326,45 @@ function enrichDaemonError(command: string, error: DaemonError): DaemonError {
339326
...(supportedOn !== undefined ? { supportedOn } : {}),
340327
};
341328
}
329+
330+
// Phase 4 (agent-cost) success-path grafts: a leveled response view and an
331+
// opt-in cost block, both purely additive. With responseLevel `default` (or
332+
// unset) AND no registered view AND no --cost, the original `response` object is
333+
// returned unchanged — byte-identical to today (Maestro `.ad` recompare safe).
334+
function applyAgentCostGrafts(
335+
req: DaemonRequest,
336+
response: Extract<DaemonResponse, { ok: true }>,
337+
startedAt: number,
338+
): DaemonResponse {
339+
const viewed = applyResponseLevelView(req, response);
340+
if (!req.meta?.includeCost) return viewed;
341+
const cost = buildResponseCost(response.data, startedAt);
342+
return { ok: true, data: { ...(viewed.data ?? {}), cost } };
343+
}
344+
345+
// Returns the response untouched when responseLevel is `default` (or unset) or no
346+
// view is registered for the command — preserving today's byte-exact wire shape.
347+
function applyResponseLevelView(
348+
req: DaemonRequest,
349+
response: Extract<DaemonResponse, { ok: true }>,
350+
): Extract<DaemonResponse, { ok: true }> {
351+
const level = req.meta?.responseLevel ?? 'default';
352+
if (level === 'default') return response;
353+
const view = RESPONSE_VIEWS[req.command];
354+
return view ? { ok: true, data: view(response.data ?? {}, level) } : response;
355+
}
356+
357+
function buildResponseCost(
358+
originalData: DaemonResponseData | undefined,
359+
startedAt: number,
360+
): ResponseCost {
361+
const cost: ResponseCost = {
362+
wallClockMs: Date.now() - startedAt,
363+
runnerRoundTrips: countDiagnosticEventsByPhase(RUNNER_ROUND_TRIP_PHASES),
364+
};
365+
// nodeCount reads the ORIGINAL node tree (the digest view may have already
366+
// collapsed `data.nodes`), so the count stays accurate.
367+
const nodes = originalData?.nodes;
368+
if (Array.isArray(nodes)) cost.nodeCount = nodes.length;
369+
return cost;
370+
}

0 commit comments

Comments
 (0)