diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index f3076d1f0..0bd732989 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -807,3 +807,72 @@ test('sessions.stateDir resolves locally without contacting the daemon', async ( assert.equal(fromOverride, '/tmp/agent-device-override-state'); assert.equal(setup.calls.length, 0); }); + +test('capture.screenshot passes a digest (non-default level) payload through unnormalized', async () => { + const digest = { + path: '/tmp/shot.png', + overlayCount: 2, + overlayRefs: [{ ref: 'e1', label: 'Login' }], + artifacts: [{ field: 'path', artifactId: 'a1' }], + }; + const setup = createTransport(async (req) => { + assert.equal(req.command, 'screenshot'); + assert.equal(req.meta?.responseLevel, 'digest'); // the level reached the daemon + return { ok: true, data: digest }; + }); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + + const result = await client.capture.screenshot({ responseLevel: 'digest' }); + + // The digest SURVIVES the shipped client path: overlayCount, the leveled + // overlayRefs ({ref,label}), and artifacts are not dropped, and the default + // normalizer (which would add `identifiers` and strip those fields) is skipped. + const asRecord = result as Record; + assert.deepEqual(asRecord, digest); + assert.equal(asRecord.overlayCount, 2); + assert.ok(!('identifiers' in asRecord)); +}); + +test('capture.screenshot normalizes the default-level result (unchanged)', async () => { + const setup = createTransport(async (req) => { + assert.equal(req.command, 'screenshot'); + assert.equal(req.meta?.responseLevel, undefined); + return { + ok: true, + data: { + path: '/tmp/shot.png', + overlayRefs: [{ ref: 'e1', label: 'Login', x: 0, y: 0, width: 10, height: 10 }], + }, + }; + }); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + + const result = await client.capture.screenshot(); + + assert.equal(result.path, '/tmp/shot.png'); + assert.deepEqual(result.identifiers, { session: 'qa' }); +}); + +test('capture.snapshot passes a digest (non-default level) payload through unnormalized', async () => { + const digest = { + nodeCount: 3, + refs: [{ ref: 'e1', label: 'Login' }], + truncated: false, + }; + const setup = createTransport(async (req) => { + assert.equal(req.command, 'snapshot'); + assert.equal(req.meta?.responseLevel, 'digest'); // the level reached the daemon + return { ok: true, data: digest }; + }); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + + const result = await client.capture.snapshot({ responseLevel: 'digest' }); + + // The digest SURVIVES the client path: nodeCount/refs are preserved and the + // default normalizer (which expects `nodes` and would yield an empty snapshot + // plus `identifiers`) is skipped. + const asRecord = result as Record; + assert.deepEqual(asRecord, digest); + assert.equal(asRecord.nodeCount, 3); + assert.ok(!('identifiers' in asRecord)); +}); diff --git a/src/cli/commands/__tests__/generic.test.ts b/src/cli/commands/__tests__/generic.test.ts new file mode 100644 index 000000000..3873e3009 --- /dev/null +++ b/src/cli/commands/__tests__/generic.test.ts @@ -0,0 +1,51 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { createAgentDeviceClient } from '../../../client.ts'; +import type { DaemonResponse } from '../../../contracts.ts'; +import type { CliFlags } from '../../../utils/cli-flags.ts'; +import type { ClientBackedCliCommandName } from '../../../command-catalog.ts'; +import { runGenericClientBackedCommand } from '../generic.ts'; + +async function captureStdout(fn: () => Promise): Promise { + const chunks: string[] = []; + const original = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: unknown) => { + chunks.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + try { + await fn(); + } finally { + process.stdout.write = original; + } + return chunks.join(''); +} + +test('snapshot --level digest --json preserves the digest through the generic CLI path', async () => { + const digest = { nodeCount: 3, refs: [{ ref: 'e1', label: 'Login' }], truncated: false }; + const client = createAgentDeviceClient( + { session: 'qa', responseLevel: 'digest' }, + { + transport: async (req): Promise => { + assert.equal(req.command, 'snapshot'); + return { ok: true, data: digest }; + }, + }, + ); + const flags = { json: true, responseLevel: 'digest' } as CliFlags; + + const out = await captureStdout(() => + runGenericClientBackedCommand({ + command: 'snapshot' as ClientBackedCliCommandName, + positionals: [], + flags, + client, + }), + ); + const parsed = JSON.parse(out) as { success: boolean; data: Record }; + + assert.equal(parsed.success, true); + // nodeCount/refs — the digest fields — are preserved, not collapsed by the + // snapshot formatter that expects `nodes`. + assert.deepEqual(parsed.data, digest); +}); diff --git a/src/cli/commands/__tests__/screenshot.test.ts b/src/cli/commands/__tests__/screenshot.test.ts new file mode 100644 index 000000000..4062bf16a --- /dev/null +++ b/src/cli/commands/__tests__/screenshot.test.ts @@ -0,0 +1,69 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { createAgentDeviceClient } from '../../../client.ts'; +import type { DaemonResponse } from '../../../contracts.ts'; +import type { CliFlags } from '../../../utils/cli-flags.ts'; +import { screenshotCommand } from '../screenshot.ts'; + +async function captureStdout(fn: () => Promise): Promise { + const chunks: string[] = []; + const original = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: unknown) => { + chunks.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + try { + await fn(); + } finally { + process.stdout.write = original; + } + return chunks.join(''); +} + +function clientReturning( + data: Record, + responseLevel?: 'digest' | 'default' | 'full', +) { + return createAgentDeviceClient( + { session: 'qa', ...(responseLevel ? { responseLevel } : {}) }, + { + transport: async (req): Promise => { + assert.equal(req.command, 'screenshot'); + return { ok: true, data }; + }, + }, + ); +} + +test('screenshot --level digest --json preserves the digest payload through the CLI', async () => { + const digest = { + path: '/tmp/shot.png', + overlayCount: 2, + overlayRefs: [{ ref: 'e1', label: 'Login' }], + artifacts: [{ field: 'path', artifactId: 'a1' }], + }; + const client = clientReturning(digest, 'digest'); + const flags = { json: true, responseLevel: 'digest' } as CliFlags; + + const out = await captureStdout(() => screenshotCommand({ positionals: [], flags, client })); + const parsed = JSON.parse(out) as { success: boolean; data: Record }; + + assert.equal(parsed.success, true); + // overlayCount and artifacts — the useful digest fields — are NOT dropped. + assert.deepEqual(parsed.data, digest); +}); + +test('screenshot --json at the default level still emits the normalized { path, overlayRefs } shape', async () => { + const full = { + path: '/tmp/shot.png', + overlayRefs: [{ ref: 'e1', label: 'Login', x: 0, y: 0, width: 10, height: 10 }], + }; + const client = clientReturning(full); + const flags = { json: true } as CliFlags; + + const out = await captureStdout(() => screenshotCommand({ positionals: [], flags, client })); + const parsed = JSON.parse(out) as { data: { path: string; overlayCount?: number } }; + + assert.equal(parsed.data.path, '/tmp/shot.png'); + assert.ok(!('overlayCount' in parsed.data)); +}); diff --git a/src/cli/commands/generic.ts b/src/cli/commands/generic.ts index 82665dd1d..aa5dc3758 100644 --- a/src/cli/commands/generic.ts +++ b/src/cli/commands/generic.ts @@ -6,6 +6,7 @@ import type { CliOutput } from '../../commands/command-contract.ts'; import type { ReplaySuiteResult } from '../../daemon/types.ts'; import type { CliFlags } from '../../utils/cli-flags.ts'; import { readCommandMessage } from '../../utils/success-text.ts'; +import { isNonDefaultResponseLevel } from '../../contracts.ts'; import { writeCommandOutput } from './shared.ts'; import type { ClientBackedCliCommandName } from '../../command-catalog.ts'; import type { ClientCommandParams } from './router-types.ts'; @@ -23,6 +24,14 @@ export async function runGenericClientBackedCommand({ positionals, flags, }); + // A non-default responseLevel returns a leveled payload (e.g. the snapshot + // digest { nodeCount, refs }) that the per-command CLI formatters assume away — + // they serialize the default shape and drop the digest fields. Emit the leveled + // payload verbatim instead. + if (isNonDefaultResponseLevel(flags.responseLevel)) { + writeCommandOutput(flags, result, () => JSON.stringify(result, null, 2)); + return true; + } if (cliOutput) { writeCliOutput(flags, cliOutput); } else { diff --git a/src/cli/commands/screenshot.ts b/src/cli/commands/screenshot.ts index 883877666..465dc612f 100644 --- a/src/cli/commands/screenshot.ts +++ b/src/cli/commands/screenshot.ts @@ -1,5 +1,6 @@ import { formatScreenshotDiffText, formatSnapshotDiffText } from '../../utils/output.ts'; import { AppError } from '../../kernel/errors.ts'; +import { isNonDefaultResponseLevel } from '../../contracts.ts'; import { resolveUserPath } from '../../utils/path-resolution.ts'; import type { AgentDeviceBackend } from '../../backend.ts'; import type { AgentDeviceClient, CaptureScreenshotResult } from '../../client.ts'; @@ -17,6 +18,13 @@ export const screenshotCommand: ClientCommandHandler = async ({ positionals, fla positionals, flags, })) as CaptureScreenshotResult; + // A non-default responseLevel returns a leveled (digest) payload — overlayCount, + // artifacts, leveled overlayRefs. Rebuilding the default { path, overlayRefs } + // shape would drop those, so emit the leveled payload verbatim. + if (isNonDefaultResponseLevel(flags.responseLevel)) { + writeCommandOutput(flags, result, () => JSON.stringify(result, null, 2)); + return true; + } const data = { path: result.path, ...(result.overlayRefs ? { overlayRefs: result.overlayRefs } : {}), diff --git a/src/client.ts b/src/client.ts index ae0dd5f38..30076d7ee 100644 --- a/src/client.ts +++ b/src/client.ts @@ -38,6 +38,7 @@ import type { AppListOptions, AppOpenOptions, CaptureScreenshotOptions, + CaptureScreenshotResult, CaptureSnapshotOptions, CaptureSnapshotResult, InternalRequestOptions, @@ -46,6 +47,7 @@ import type { MetroPrepareOptions, } from './client-types.ts'; import type { CommandResult } from './core/command-descriptor/command-result.ts'; +import { isNonDefaultResponseLevel, type ResponseLevel } from './contracts.ts'; import { readSerializedSnapshotCaptureAnnotations } from './snapshot-capture-annotations.ts'; import { readSnapshotDiagnosticsSummary } from './snapshot-diagnostics.ts'; import type { CommandFlags } from './core/dispatch-context.ts'; @@ -56,6 +58,12 @@ export function createAgentDeviceClient( ): AgentDeviceClient { const transport = deps.transport ?? sendToDaemon; + // A non-default responseLevel (digest/full) makes the daemon return a leveled + // shape; the per-command client normalizers assume the default shape, so the + // capture methods pass the leveled payload through unnormalized instead. + const isLeveledResponse = (options: { responseLevel?: ResponseLevel }): boolean => + isNonDefaultResponseLevel(options.responseLevel ?? config.responseLevel); + const execute = async ( command: string, positionals: string[] = [], @@ -266,11 +274,23 @@ export function createAgentDeviceClient( snapshot: async (options: CaptureSnapshotOptions = {}) => { const session = resolveRequestSession(options); const data = await executeCommand>('snapshot', options); + // A non-default responseLevel returns the leveled snapshot digest + // ({ nodeCount, refs, … }); normalizeSnapshotResult expects the full + // `nodes` tree and would collapse it to an empty snapshot. Pass the + // leveled payload through verbatim. (Mirrors capture.screenshot; the + // caller opted into the level, so the runtime value is the leveled shape.) + if (isLeveledResponse(options)) return data as unknown as CaptureSnapshotResult; return normalizeSnapshotResult(data, session); }, screenshot: async (options: CaptureScreenshotOptions = {}) => { const session = resolveRequestSession(options); const data = await executeCommand>('screenshot', options); + // A non-default responseLevel returns a leveled (digest) screenshot shape + // — `overlayCount`, leveled `overlayRefs`, `artifacts` — that the default + // normalizer below would drop. Pass the leveled payload through verbatim. + // (The caller opted into a non-default level, so the static type is the + // default shape; the runtime value is the leveled payload.) + if (isLeveledResponse(options)) return data as unknown as CaptureScreenshotResult; const screenshot = readScreenshotResultData(data); return { path: readRequiredString(data, 'path'), diff --git a/src/contracts.ts b/src/contracts.ts index 4209de839..147d5680d 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -64,6 +64,15 @@ export type NetworkIncludeMode = (typeof NETWORK_INCLUDE_MODES)[number]; export const RESPONSE_LEVELS = ['digest', 'default', 'full'] as const; export type ResponseLevel = (typeof RESPONSE_LEVELS)[number]; +/** + * Whether a response level changes the wire shape from today's default. Used by + * the client/CLI/MCP rendering boundaries to pass a leveled payload through + * verbatim instead of running it through default-shape formatters. + */ +export function isNonDefaultResponseLevel(level: ResponseLevel | undefined): boolean { + return level !== undefined && level !== 'default'; +} + export type DaemonRequestMeta = { requestId?: string; debug?: boolean; diff --git a/src/daemon/__tests__/response-views.test.ts b/src/daemon/__tests__/response-views.test.ts index e8d502a9c..5dc3e510e 100644 --- a/src/daemon/__tests__/response-views.test.ts +++ b/src/daemon/__tests__/response-views.test.ts @@ -3,6 +3,7 @@ import { RESPONSE_VIEWS } from '../response-views.ts'; import type { DaemonResponseData } from '../types.ts'; const snapshotView = RESPONSE_VIEWS.snapshot; +const screenshotView = RESPONSE_VIEWS.screenshot; const SNAPSHOT_DATA: DaemonResponseData = { nodes: [ @@ -47,3 +48,58 @@ test('digest tolerates missing/empty node trees', () => { const digest = snapshotView!({ truncated: true }, 'digest'); expect(digest).toMatchObject({ nodeCount: 0, refs: [], truncated: true }); }); + +const overlayRef = (ref: string, label: string | undefined) => ({ + ref, + ...(label !== undefined ? { label } : {}), + rect: { x: 0, y: 0, width: 40, height: 20 }, + overlayRect: { x: 0, y: 0, width: 100, height: 50 }, + center: { x: 50, y: 25 }, +}); + +const SCREENSHOT_DATA: DaemonResponseData = { + path: '/tmp/agent-device-screenshot-xyz/screenshot.png', + overlayRefs: [ + overlayRef('e1', 'Continue'), + overlayRef('e2', undefined), // label omitted → stays undefined in the digest + ], + artifacts: [{ field: 'path', artifactId: 'art-1', fileName: 'screenshot.png' }], // cheap retrieval handle — preserved +}; + +test('screenshot view is registered', () => { + expect(typeof screenshotView).toBe('function'); +}); + +test('digest collapses overlay geometry to count + leveled refs, keeps cheap fields', () => { + const digest = screenshotView!(SCREENSHOT_DATA, 'digest'); + expect(digest).toEqual({ + path: '/tmp/agent-device-screenshot-xyz/screenshot.png', + overlayCount: 2, + overlayRefs: [ + { ref: 'e1', label: 'Continue' }, + { ref: 'e2', label: undefined }, + ], + artifacts: [{ field: 'path', artifactId: 'art-1', fileName: 'screenshot.png' }], + }); + // The per-overlay geometry (the token sink) is dropped from every ref. + expect(digest.overlayRefs).not.toContainEqual( + expect.objectContaining({ rect: expect.anything() }), + ); +}); + +test('digest caps the overlay list at 12 while counting them all', () => { + const overlayRefs = Array.from({ length: 20 }, (_, i) => overlayRef(`e${i + 1}`, `L${i + 1}`)); + const digest = screenshotView!({ path: '/tmp/s.png', overlayRefs }, 'digest'); + expect(digest.overlayCount).toBe(20); + expect(Array.isArray(digest.overlayRefs) && digest.overlayRefs).toHaveLength(12); +}); + +test('screenshot default and full return today’s shape unchanged (same reference)', () => { + expect(screenshotView!(SCREENSHOT_DATA, 'default')).toBe(SCREENSHOT_DATA); + expect(screenshotView!(SCREENSHOT_DATA, 'full')).toBe(SCREENSHOT_DATA); +}); + +test('screenshot digest tolerates a path-only result with no overlay refs', () => { + const digest = screenshotView!({ path: '/tmp/s.png' }, 'digest'); + expect(digest).toEqual({ path: '/tmp/s.png', overlayCount: 0, overlayRefs: [] }); +}); diff --git a/src/daemon/response-views.ts b/src/daemon/response-views.ts index 5e28fcfa3..65c8b3655 100644 --- a/src/daemon/response-views.ts +++ b/src/daemon/response-views.ts @@ -1,5 +1,5 @@ import type { ResponseLevel } from '../contracts.ts'; -import type { SnapshotNode } from '../kernel/snapshot.ts'; +import type { ScreenshotOverlayRef, SnapshotNode } from '../kernel/snapshot.ts'; import type { DaemonResponseData } from './types.ts'; /** @@ -36,6 +36,35 @@ function snapshotView(data: DaemonResponseData, level: ResponseLevel): DaemonRes }; } +const DIGEST_OVERLAY_LIMIT = 12; + +/** + * Token-cheap screenshot digest: the captured `path` (the primary result), the + * total overlay-ref count, and the first N overlay refs leveled down to + * `{ ref, label }`. The per-overlay geometry (`rect`/`overlayRect`/`center`) — + * the token sink that `--overlay-refs` emits when many nodes are annotated — is + * dropped and the list is capped. `artifacts` (the client's image-retrieval + * handle, grafted on by request finalization) is preserved when present so the + * screenshot stays fetchable. `full` returns today's shape unchanged (nothing + * richer is computed yet). + */ +function screenshotView(data: DaemonResponseData, level: ResponseLevel): DaemonResponseData { + if (level !== 'digest') return data; + const overlays = Array.isArray(data.overlayRefs) + ? (data.overlayRefs as ScreenshotOverlayRef[]) + : []; + const overlayRefs = overlays + .slice(0, DIGEST_OVERLAY_LIMIT) + .map((overlay) => ({ ref: overlay.ref, label: overlay.label })); + return { + ...(typeof data.path === 'string' ? { path: data.path } : {}), + overlayCount: overlays.length, + overlayRefs, + ...(data.artifacts !== undefined ? { artifacts: data.artifacts } : {}), + }; +} + export const RESPONSE_VIEWS: Record = { snapshot: snapshotView, + screenshot: screenshotView, };