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
69 changes: 69 additions & 0 deletions src/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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<string, unknown>;
assert.deepEqual(asRecord, digest);
assert.equal(asRecord.nodeCount, 3);
assert.ok(!('identifiers' in asRecord));
});
51 changes: 51 additions & 0 deletions src/cli/commands/__tests__/generic.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>): Promise<string> {
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<DaemonResponse> => {
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<string, unknown> };

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);
});
69 changes: 69 additions & 0 deletions src/cli/commands/__tests__/screenshot.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>): Promise<string> {
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<string, unknown>,
responseLevel?: 'digest' | 'default' | 'full',
) {
return createAgentDeviceClient(
{ session: 'qa', ...(responseLevel ? { responseLevel } : {}) },
{
transport: async (req): Promise<DaemonResponse> => {
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<string, unknown> };

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));
});
9 changes: 9 additions & 0 deletions src/cli/commands/generic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions src/cli/commands/screenshot.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 } : {}),
Expand Down
20 changes: 20 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import type {
AppListOptions,
AppOpenOptions,
CaptureScreenshotOptions,
CaptureScreenshotResult,
CaptureSnapshotOptions,
CaptureSnapshotResult,
InternalRequestOptions,
Expand All @@ -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';
Expand All @@ -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[] = [],
Expand Down Expand Up @@ -266,11 +274,23 @@ export function createAgentDeviceClient(
snapshot: async (options: CaptureSnapshotOptions = {}) => {
const session = resolveRequestSession(options);
const data = await executeCommand<Record<string, unknown>>('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<Record<string, unknown>>('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'),
Expand Down
9 changes: 9 additions & 0 deletions src/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
56 changes: 56 additions & 0 deletions src/daemon/__tests__/response-views.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -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: [] });
});
Loading
Loading