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
1 change: 1 addition & 0 deletions scripts/integration-progress-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ function summarizeProviderScenarioFlagExclusions() {
'version',
'verbose',
'cost',
'responseLevel',
],
},
{
Expand Down
1 change: 1 addition & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
cwd: process.cwd(),
debug: debugOutputEnabled,
cost: currentFlags.cost,
responseLevel: currentFlags.responseLevel,
});
let parsedBatchSteps: BatchStep[] | undefined;
if (command === 'batch') {
Expand Down
1 change: 1 addition & 0 deletions src/client-normalizers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,7 @@ export function buildMeta(options: InternalRequestOptions): DaemonRequest['meta'
sessionExplicit: options.session !== undefined,
debug: options.debug,
includeCost: options.cost,
responseLevel: options.responseLevel,
lockPolicy: options.lockPolicy,
lockPlatform: options.lockPlatform,
...leaseScopeToRequestMeta(leaseScope),
Expand Down
3 changes: 3 additions & 0 deletions src/client-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
DaemonResponse,
LeaseBackend,
NetworkIncludeMode,
ResponseLevel,
SessionIsolationMode,
SessionRuntimeHints,
} from './contracts.ts';
Expand Down Expand Up @@ -79,6 +80,7 @@ export type AgentDeviceClientConfig = RemoteConnectionProfileFields & {
cwd?: string;
debug?: boolean;
cost?: boolean;
responseLevel?: ResponseLevel;
iosXctestrunFile?: string;
iosXctestDerivedDataPath?: string;
iosXctestEnvDir?: string;
Expand Down Expand Up @@ -106,6 +108,7 @@ export type AgentDeviceRequestOverrides = Pick<
| 'cwd'
| 'debug'
| 'cost'
| 'responseLevel'
| 'iosXctestrunFile'
| 'iosXctestDerivedDataPath'
| 'iosXctestEnvDir'
Expand Down
8 changes: 8 additions & 0 deletions src/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,17 @@ export type SessionIsolationMode = (typeof SESSION_ISOLATION_MODES)[number];
export const NETWORK_INCLUDE_MODES = ['summary', 'headers', 'body', 'all'] as const;
export type NetworkIncludeMode = (typeof NETWORK_INCLUDE_MODES)[number];

// Agent-cost leveled response views (Phase 4). `default` == today's exact wire
// shape (Maestro `.ad` recompare safe); `digest` is a token-cheap view; `full`
// is the richest view (== default until a command surfaces extra detail).
export const RESPONSE_LEVELS = ['digest', 'default', 'full'] as const;
export type ResponseLevel = (typeof RESPONSE_LEVELS)[number];

export type DaemonRequestMeta = {
requestId?: string;
debug?: boolean;
includeCost?: boolean;
responseLevel?: ResponseLevel;
cwd?: string;
sessionExplicit?: boolean;
tenantId?: string;
Expand Down Expand Up @@ -447,6 +454,7 @@ export const daemonCommandRequestSchema = schema<DaemonRequest>((input, path) =>
requestId: optionalString(meta, 'requestId', `${path}.meta`),
debug: optionalBoolean(meta, 'debug', `${path}.meta`),
includeCost: optionalBoolean(meta, 'includeCost', `${path}.meta`),
responseLevel: optionalEnum(meta, 'responseLevel', RESPONSE_LEVELS, `${path}.meta`),
cwd: optionalString(meta, 'cwd', `${path}.meta`),
sessionExplicit: optionalBoolean(meta, 'sessionExplicit', `${path}.meta`),
tenantId: optionalString(meta, 'tenantId', `${path}.meta`),
Expand Down
153 changes: 153 additions & 0 deletions src/daemon/__tests__/request-router-response-level.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { test, expect, vi, beforeEach } from 'vitest';
import os from 'node:os';
import path from 'node:path';

vi.mock('../../core/dispatch.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../core/dispatch.ts')>();
return { ...actual, dispatchCommand: vi.fn(async () => ({})) };
});

vi.mock('../../platforms/ios/runner-client.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../platforms/ios/runner-client.ts')>();
return { ...actual, stopIosRunnerSession: vi.fn(async () => {}) };
});

vi.mock('../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) }));

// Register a test view on a command that flows through the (mocked) generic
// dispatch path, so the router graft mechanics can be exercised end to end
// without the real snapshot handler (the actual snapshot view is unit-tested in
// response-views.test.ts).
vi.mock('../response-views.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../response-views.ts')>();
return {
...actual,
RESPONSE_VIEWS: {
...actual.RESPONSE_VIEWS,
home: (data: Record<string, unknown>, level: string) =>
level === 'digest' ? { homeDigest: true, hadItems: Array.isArray(data.items) } : data,
},
};
});

import { dispatchCommand } from '../../core/dispatch.ts';
import { createRequestHandler } from '../request-router.ts';
import type { DaemonRequest, SessionState } from '../types.ts';
import { LeaseRegistry } from '../lease-registry.ts';
import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts';
import { daemonCommandRequestSchema } from '../../contracts.ts';

const mockDispatch = vi.mocked(dispatchCommand);

const REPRESENTATIVE_PAYLOAD = { message: 'home-ok', items: [1, 2, 3] } as const;

function makeIosSession(name: string): SessionState {
return {
name,
createdAt: 1_700_000_000_000,
actions: [],
device: {
platform: 'ios',
target: 'mobile',
id: 'SIM-001',
name: 'iPhone 16',
kind: 'simulator',
booted: true,
simulatorSetPath: '/tmp/tenant-a/set',
},
};
}

function makeHandler() {
const sessionStore = makeSessionStore('agent-device-router-level-');
sessionStore.set('level-session', makeIosSession('level-session'));
return {
sessionStore,
handler: createRequestHandler({
logPath: path.join(os.tmpdir(), 'daemon.log'),
token: 'test-token',
sessionStore,
leaseRegistry: new LeaseRegistry(),
trackDownloadableArtifact: () => 'artifact-id',
}),
};
}

function request(command: string, overrides: Partial<DaemonRequest> = {}): DaemonRequest {
return {
token: 'test-token',
session: 'level-session',
command,
positionals: [],
flags: {},
...overrides,
};
}

beforeEach(() => {
mockDispatch.mockReset();
mockDispatch.mockImplementation(async () => ({ ...REPRESENTATIVE_PAYLOAD }));
});

test('(a) default identity: responseLevel absent === default === no meta, byte-identical', async () => {
const { handler } = makeHandler();
const noMeta = await handler(request('home'));
const emptyMeta = await handler(request('home', { meta: {} }));
const explicitDefault = await handler(request('home', { meta: { responseLevel: 'default' } }));

expect(JSON.stringify(noMeta)).toBe(JSON.stringify(emptyMeta));
expect(JSON.stringify(noMeta)).toBe(JSON.stringify(explicitDefault));
if (noMeta.ok) expect(noMeta.data).toEqual(REPRESENTATIVE_PAYLOAD);
});

test('(b) digest applies the registered view, dropping the full payload', async () => {
const { handler } = makeHandler();
const resp = await handler(request('home', { meta: { responseLevel: 'digest' } }));
expect(resp.ok).toBe(true);
if (!resp.ok) return;
expect(resp.data).toEqual({ homeDigest: true, hadItems: true });
expect('message' in (resp.data ?? {})).toBe(false);
});

test('(c) full returns today’s shape (view passthrough) — byte-identical to default', async () => {
const { handler } = makeHandler();
const full = await handler(request('home', { meta: { responseLevel: 'full' } }));
const def = await handler(request('home', { meta: { responseLevel: 'default' } }));
expect(JSON.stringify(full)).toBe(JSON.stringify(def));
});

test('(d) digest composes with --cost: viewed data plus an additive cost block', async () => {
const { handler } = makeHandler();
const resp = await handler(
request('home', { meta: { responseLevel: 'digest', includeCost: true } }),
);
expect(resp.ok).toBe(true);
if (!resp.ok) return;
expect(resp.data).toMatchObject({ homeDigest: true, hadItems: true });
expect(typeof resp.data?.cost?.wallClockMs).toBe('number');
expect(resp.data?.cost?.runnerRoundTrips).toBe(0);
});

test('(e) digest on a command with no registered view is byte-identical to default', async () => {
const { handler } = makeHandler();
const digest = await handler(request('back', { meta: { responseLevel: 'digest' } }));
const def = await handler(request('back', { meta: {} }));
expect(JSON.stringify(digest)).toBe(JSON.stringify(def));
if (digest.ok) expect(digest.data).toEqual(REPRESENTATIVE_PAYLOAD);
});

test('(f) boundary survival: meta.responseLevel survives daemonCommandRequestSchema parsing', () => {
const parsed = daemonCommandRequestSchema.parse({
command: 'snapshot',
positionals: [],
meta: { responseLevel: 'digest' },
});
expect(parsed.meta?.responseLevel).toBe('digest');

const parsedOff = daemonCommandRequestSchema.parse({
command: 'snapshot',
positionals: [],
meta: {},
});
expect(parsedOff.meta?.responseLevel).toBeUndefined();
});
49 changes: 49 additions & 0 deletions src/daemon/__tests__/response-views.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { test, expect } from 'vitest';
import { RESPONSE_VIEWS } from '../response-views.ts';
import type { DaemonResponseData } from '../types.ts';

const snapshotView = RESPONSE_VIEWS.snapshot;

const SNAPSHOT_DATA: DaemonResponseData = {
nodes: [
{ ref: 'e1', hittable: true, label: 'Login' },
{ ref: 'e2', hittable: false, label: 'Heading' }, // not hittable → excluded
{ ref: 'e3', hittable: true, interactionBlocked: 'covered', label: 'Hidden' }, // occluded → excluded
{ ref: 'e4', hittable: true, value: 'from-value' }, // label falls back to value
],
truncated: false,
visibility: { partial: false, visibleNodeCount: 4, totalNodeCount: 4, reasons: [] },
snapshotQuality: { state: 'healthy', backend: 'tree' },
appName: 'Demo', // a non-cheap field that the digest intentionally drops
};

test('snapshot view is registered', () => {
expect(typeof snapshotView).toBe('function');
});

test('digest collapses the node tree to count + actionable refs + cheap signals', () => {
const digest = snapshotView!(SNAPSHOT_DATA, 'digest');
expect(digest).toEqual({
nodeCount: 4,
refs: [
{ ref: 'e1', label: 'Login' },
{ ref: 'e4', label: 'from-value' },
],
truncated: false,
visibility: { partial: false, visibleNodeCount: 4, totalNodeCount: 4, reasons: [] },
snapshotQuality: { state: 'healthy', backend: 'tree' },
});
// The full node tree (the token sink) and non-cheap fields are dropped.
expect('nodes' in digest).toBe(false);
expect('appName' in digest).toBe(false);
});

test('default and full return today’s shape unchanged (same reference)', () => {
expect(snapshotView!(SNAPSHOT_DATA, 'default')).toBe(SNAPSHOT_DATA);
expect(snapshotView!(SNAPSHOT_DATA, 'full')).toBe(SNAPSHOT_DATA);
});

test('digest tolerates missing/empty node trees', () => {
const digest = snapshotView!({ truncated: true }, 'digest');
expect(digest).toMatchObject({ nodeCount: 0, refs: [], truncated: true });
});
65 changes: 47 additions & 18 deletions src/daemon/request-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { AppError, normalizeError, retriableForErrorCode } from '../kernel/error
import { supportedPlatformsForCommand } from '../core/capabilities.ts';
import { timingSafeStringEqual } from '../utils/timing-safe-equal.ts';
import type { DaemonError, ResponseCost } from '../contracts.ts';
import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from './types.ts';
import type { DaemonInvokeFn, DaemonRequest, DaemonResponse, DaemonResponseData } from './types.ts';
import { RESPONSE_VIEWS } from './response-views.ts';
import { SessionStore } from './session-store.ts';
import { noActiveSessionError } from './handlers/response.ts';
import {
Expand Down Expand Up @@ -116,23 +117,9 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn {
if (!response.ok) {
return { ok: false, error: enrichDaemonError(req.command, response.error) };
}
// Phase 4 (agent-cost) graft: cost is purely additive and opt-in. With
// the flag off the serialized DaemonResponse is byte-identical to today
// (Maestro `.ad` recompare diffs it). Mirrors the conditional
// `registerDownloadableArtifacts` spread in request-finalization. Runs
// inside the diagnostics scope so it can read this request's accumulated
// runner-round-trip tally.
if (!req.meta?.includeCost) return response;
const cost: ResponseCost = {
wallClockMs: Date.now() - start,
runnerRoundTrips: countDiagnosticEventsByPhase(RUNNER_ROUND_TRIP_PHASES),
};
// Generic, command-agnostic: only the node-tree commands (snapshot) put a
// `nodes` array on response.data, so this reads as a number there and is
// omitted everywhere else.
const nodes = response.data?.nodes;
if (Array.isArray(nodes)) cost.nodeCount = nodes.length;
return { ok: true, data: { ...(response.data ?? {}), cost } };
// Phase 4 (agent-cost) grafts on the success path. Runs inside the
// diagnostics scope so cost can read this request's runner-round-trip tally.
return applyAgentCostGrafts(req, response, start);
},
);
}
Expand Down Expand Up @@ -339,3 +326,45 @@ function enrichDaemonError(command: string, error: DaemonError): DaemonError {
...(supportedOn !== undefined ? { supportedOn } : {}),
};
}

// Phase 4 (agent-cost) success-path grafts: a leveled response view and an
// opt-in cost block, both purely additive. With responseLevel `default` (or
// unset) AND no registered view AND no --cost, the original `response` object is
// returned unchanged — byte-identical to today (Maestro `.ad` recompare safe).
function applyAgentCostGrafts(
req: DaemonRequest,
response: Extract<DaemonResponse, { ok: true }>,
startedAt: number,
): DaemonResponse {
const viewed = applyResponseLevelView(req, response);
if (!req.meta?.includeCost) return viewed;
const cost = buildResponseCost(response.data, startedAt);
return { ok: true, data: { ...(viewed.data ?? {}), cost } };
}

// Returns the response untouched when responseLevel is `default` (or unset) or no
// view is registered for the command — preserving today's byte-exact wire shape.
function applyResponseLevelView(
req: DaemonRequest,
response: Extract<DaemonResponse, { ok: true }>,
): Extract<DaemonResponse, { ok: true }> {
const level = req.meta?.responseLevel ?? 'default';
if (level === 'default') return response;
const view = RESPONSE_VIEWS[req.command];
return view ? { ok: true, data: view(response.data ?? {}, level) } : response;
}

function buildResponseCost(
originalData: DaemonResponseData | undefined,
startedAt: number,
): ResponseCost {
const cost: ResponseCost = {
wallClockMs: Date.now() - startedAt,
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.
const nodes = originalData?.nodes;
if (Array.isArray(nodes)) cost.nodeCount = nodes.length;
return cost;
}
Loading
Loading