From 285f95a107d5f0bf5cb7496420a081ff88f11c83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 30 Jun 2026 07:58:14 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20screenshot=20digest=20response-view?= =?UTF-8?q?=20=E2=80=94=20Phase=204?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `screenshot` entry to the Phase 4 RESPONSE_VIEWS registry. At responseLevel `digest`, the view keeps the cheap result fields — the captured `path` and the `artifacts` retrieval handle — and collapses the token-heavy `overlayRefs` array (each carrying ref + label + three geometry rects) to a total `overlayCount` plus the first 12 refs leveled down to `{ ref, label }`. `default`/`full` return today's shape unchanged, so unregistered and default-level responses stay byte-identical. --- src/daemon/__tests__/response-views.test.ts | 56 +++++++++++++++++++++ src/daemon/response-views.ts | 31 +++++++++++- 2 files changed, 86 insertions(+), 1 deletion(-) 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, }; From ecc89290e8f36208cf63b10ef336b4e86dcbaa7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 30 Jun 2026 08:38:26 +0200 Subject: [PATCH 2/4] fix: preserve the screenshot digest through the client capture path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daemon screenshot view returns a leveled digest (path, overlayCount, leveled overlayRefs, artifacts), but client.capture.screenshot() always ran the data through readScreenshotResultData, which keeps only path + full-geometry overlay refs and drops overlayCount/artifacts — so the digest never reached SDK/CLI callers. Make the capture method level-aware: when the effective responseLevel (request override or client config) is non-default, return the leveled payload verbatim instead of normalizing it. Default-level behavior is unchanged. Adds shipped-path client tests: capture.screenshot({ responseLevel: 'digest' }) returns the raw digest (overlayCount/artifacts preserved, no normalizer identifiers); the default path still normalizes. Kept the return type as CaptureScreenshotResult (the union variant cascaded into many default-path consumers); the caller opted into the level, so the runtime value is leveled. --- src/__tests__/client.test.ts | 45 ++++++++++++++++++++++++++++++++++++ src/client.ts | 16 +++++++++++++ 2 files changed, 61 insertions(+) diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index f3076d1f0..dd380a793 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -807,3 +807,48 @@ 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' }); +}); diff --git a/src/client.ts b/src/client.ts index ae0dd5f38..d5d2ddef3 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 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,14 @@ 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 => { + const level = options.responseLevel ?? config.responseLevel; + return level !== undefined && level !== 'default'; + }; + const execute = async ( command: string, positionals: string[] = [], @@ -271,6 +281,12 @@ export function createAgentDeviceClient( 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'), From 397a6634743280e41c24074cb228dd71a986519d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 30 Jun 2026 09:44:39 +0200 Subject: [PATCH 3/4] fix: preserve the screenshot digest through the CLI command path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agent-device screenshot --level digest --json still dropped overlayCount and artifacts: the CLI command rebuilt the default { path, overlayRefs } shape from the (now leveled) client result. Make screenshotCommand level-aware — for a non-default responseLevel it emits the leveled payload verbatim (JSON / JSON text). Adds a CLI shipped-path test (--level digest --json preserves the digest; the default level still emits the normalized shape). Factors the predicate into a shared isNonDefaultResponseLevel in contracts.ts, reused by the client helper. --- src/cli/commands/__tests__/screenshot.test.ts | 69 +++++++++++++++++++ src/cli/commands/screenshot.ts | 8 +++ src/client.ts | 8 +-- src/contracts.ts | 9 +++ 4 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 src/cli/commands/__tests__/screenshot.test.ts 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/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 d5d2ddef3..089de6a09 100644 --- a/src/client.ts +++ b/src/client.ts @@ -47,7 +47,7 @@ import type { MetroPrepareOptions, } from './client-types.ts'; import type { CommandResult } from './core/command-descriptor/command-result.ts'; -import type { ResponseLevel } from './contracts.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'; @@ -61,10 +61,8 @@ export function createAgentDeviceClient( // 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 => { - const level = options.responseLevel ?? config.responseLevel; - return level !== undefined && level !== 'default'; - }; + const isLeveledResponse = (options: { responseLevel?: ResponseLevel }): boolean => + isNonDefaultResponseLevel(options.responseLevel ?? config.responseLevel); const execute = async ( command: string, 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; From 69ef3c334f5fc10c3d14dedeef3718ac690a3ebf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 30 Jun 2026 10:33:26 +0200 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20preserve=20snapshot=20digest=20throu?= =?UTF-8?q?gh=20the=20client=20capture=20path=20=E2=80=94=20Phase=204=20(#?= =?UTF-8?q?949)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: preserve the snapshot digest through the client capture path client.capture.snapshot() always ran the daemon data through normalizeSnapshotResult, which expects the full `nodes` tree — so a non-default responseLevel digest ({ nodeCount, refs }) collapsed to an empty snapshot, the same gap #945 fixed for screenshot. Make it level-aware: when the effective responseLevel is non-default, return the leveled payload verbatim. Default-level behavior is unchanged. Adds a shipped-path client test asserting the digest survives (nodeCount/refs preserved, no normalizer identifiers). * fix: preserve leveled digests through the generic CLI path agent-device snapshot --level digest --json dropped nodeCount/refs: snapshot goes through the generic CLI path (runCliCommandWithOutput -> formatCliOutput), whose snapshot formatter serializes the default CaptureSnapshotResult shape and discards digest-only fields. Make runGenericClientBackedCommand level-aware: for a non-default responseLevel it emits the raw leveled payload verbatim (JSON / JSON text), bypassing the default-shape formatter. This generalizes to any generic-path command. Adds a snapshot CLI shipped-path test. --- src/__tests__/client.test.ts | 24 ++++++++++ src/cli/commands/__tests__/generic.test.ts | 51 ++++++++++++++++++++++ src/cli/commands/generic.ts | 9 ++++ src/client.ts | 6 +++ 4 files changed, 90 insertions(+) create mode 100644 src/cli/commands/__tests__/generic.test.ts diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index dd380a793..0bd732989 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -852,3 +852,27 @@ test('capture.screenshot normalizes the default-level result (unchanged)', async 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/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/client.ts b/src/client.ts index 089de6a09..30076d7ee 100644 --- a/src/client.ts +++ b/src/client.ts @@ -274,6 +274,12 @@ 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 = {}) => {