Skip to content

Commit f378050

Browse files
authored
feat(snapshot): attach a fallback screenshot to sparse captures (#1764)
A sparse verdict already tells the caller to use a screenshot as visual truth, which made that screenshot the guaranteed next command on every unreadable screen — a second round trip to obey advice we authored. The user-facing `snapshot` dispatch now takes the shot itself and links the path in its warnings. The fallback is deliberately hung off `dispatchSnapshotViaRuntime` and skipped for internal observations: selector resolution, settle, and wait polling reach `captureSnapshot` directly, so a wait polling an unreadable screen cannot turn into a screenshot per poll. A failed shot is swallowed — the verdict's own warning still carries the manual remedy, so the fallback can never fail the snapshot that was asked for. Sparse captures also say when the screen is the app's problem. Only the `sparse-tree` reason code is evidence about the app: every backend reached the screen and it published no semantic content, which is the same emptiness assistive tech gets. `ax-rejected`, `budget`, `no-nodes` and `capture-failed` are limits of this tool and stay unattributed, so readers are not sent to file bugs against code that is not broken.
1 parent 13c482c commit f378050

16 files changed

Lines changed: 421 additions & 12 deletions

CONTEXT.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -520,7 +520,9 @@ the observable freshness and failure semantics below before any runtime refactor
520520
stabilization is active.
521521
- Sparse snapshot quality verdicts are observable failures. Sparse captures must not replace
522522
`session.snapshot`, and selector routes should report the sparse verdict instead of treating a
523-
root-only or sparse tree as an empty UI.
523+
root-only or sparse tree as an empty UI. The user-facing `snapshot` dispatch publishes a fallback
524+
screenshot through the response artifact channel (`fallbackScreenshotPath`); internal observations
525+
never do, so a polling wait cannot turn an unreadable screen into one screenshot per poll.
524526
- iOS sparse and AX failures are not proof of empty UI. Regular visible snapshots can recover through
525527
the capture plan; raw and strict paths preserve failure. `runnerFatal` invalidates the cached target
526528
and must never refresh healthy mutation recency.

packages/contracts/src/client-capture.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ export type CaptureSnapshotResult = {
4343
visibility?: SnapshotVisibility;
4444
unchanged?: SnapshotUnchanged;
4545
snapshotDiagnostics?: SnapshotDiagnosticsSummary;
46+
/**
47+
* Screenshot captured automatically when the semantic snapshot was sparse.
48+
* Remote clients receive a materialized local path through the daemon artifact channel.
49+
*/
50+
fallbackScreenshotPath?: string;
4651
identifiers: AgentDeviceIdentifiers;
4752
/**
4853
* ADR 0014: the response-level ref-frame epoch the plain node refs were minted

src/agent-device-client.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,7 @@ function optionalSnapshotResponseFields(
547547
| 'warnings'
548548
| 'snapshotQuality'
549549
| 'snapshotDiagnostics'
550+
| 'fallbackScreenshotPath'
550551
| 'refsGeneration'
551552
>
552553
> {
@@ -558,6 +559,9 @@ function optionalSnapshotResponseFields(
558559
...readSerializedSnapshotCaptureAnnotations(data),
559560
...(unchanged ? { unchanged: unchanged as CaptureSnapshotResult['unchanged'] } : {}),
560561
...(snapshotDiagnostics ? { snapshotDiagnostics } : {}),
562+
...(typeof data.fallbackScreenshotPath === 'string'
563+
? { fallbackScreenshotPath: data.fallbackScreenshotPath }
564+
: {}),
561565
// ADR 0014: keep the response-level ref-frame generation on Node.js results
562566
// so callers can pin refs (`@e12~s<refsGeneration>`) before a mutation.
563567
...(typeof data.refsGeneration === 'number' ? { refsGeneration: data.refsGeneration } : {}),

src/commands/capture/output.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,21 @@ test('distinct labels across the chain are all preserved', () => {
7676
assert.equal(jsonNodes[0]!.label, 'Map');
7777
assert.equal(jsonNodes[1]!.label, 'Anthropic HQ');
7878
});
79+
80+
test('snapshot output presents the materialized fallback screenshot path', () => {
81+
const result = {
82+
...buildResult([]),
83+
fallbackScreenshotPath: '/client/artifacts/snapshot-fallback.png',
84+
};
85+
86+
const output = snapshotCliOutput({ result });
87+
88+
assert.equal(
89+
(output.jsonData as Record<string, unknown>).fallbackScreenshotPath,
90+
'/client/artifacts/snapshot-fallback.png',
91+
);
92+
assert.match(
93+
output.text ?? '',
94+
/Captured a screenshot of this screen automatically as visual truth: \/client\/artifacts\/snapshot-fallback\.png/,
95+
);
96+
});

src/daemon/__tests__/request-finalization.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,76 @@ test('finalizeDaemonResponse registers downloadable artifact type', () => {
115115
]);
116116
});
117117

118+
test('finalizeDaemonResponse registers an unexpected output artifact without a client path', () => {
119+
const req: DaemonRequest = {
120+
token: 'token',
121+
session: 'default',
122+
command: 'snapshot',
123+
positionals: [],
124+
meta: { tenantId: 'tenant-a' },
125+
};
126+
const response: DaemonResponse = {
127+
ok: true,
128+
data: {
129+
fallbackScreenshotPath: '/tmp/snapshot-fallback.png',
130+
artifacts: [
131+
{
132+
field: 'fallbackScreenshotPath',
133+
artifactType: 'screenshot',
134+
path: '/tmp/snapshot-fallback.png',
135+
fileName: 'snapshot-fallback.png',
136+
},
137+
],
138+
},
139+
};
140+
141+
const finalized = finalizeDaemonResponse(req, response, () => 'artifact-id');
142+
143+
expect(finalized).toEqual({
144+
ok: true,
145+
data: {
146+
fallbackScreenshotPath: '/tmp/snapshot-fallback.png',
147+
artifacts: [
148+
{
149+
field: 'fallbackScreenshotPath',
150+
artifactType: 'screenshot',
151+
artifactId: 'artifact-id',
152+
fileName: 'snapshot-fallback.png',
153+
localPath: undefined,
154+
},
155+
],
156+
},
157+
});
158+
});
159+
160+
test('finalizeDaemonResponse leaves unrelated local-path artifacts unregistered', () => {
161+
const req: DaemonRequest = {
162+
token: 'token',
163+
session: 'default',
164+
command: 'record',
165+
positionals: ['stop'],
166+
};
167+
const response: DaemonResponse = {
168+
ok: true,
169+
data: {
170+
artifacts: [
171+
{
172+
field: 'recordingPath',
173+
artifactType: 'screen-recording',
174+
path: '/tmp/recording.mp4',
175+
fileName: 'recording.mp4',
176+
},
177+
],
178+
},
179+
};
180+
181+
const finalized = finalizeDaemonResponse(req, response, () => {
182+
throw new Error('local-only artifact must not be registered');
183+
});
184+
185+
expect(finalized).toEqual(response);
186+
});
187+
118188
test('finalizeDaemonResponse keeps screenshot path fallback as screenshot artifact type', () => {
119189
const req: DaemonRequest = {
120190
token: 'token',

src/daemon/__tests__/response-views.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,34 @@ test('digest tolerates missing/empty node trees', () => {
5151
expect(digest).toMatchObject({ nodeCount: 0, refs: [], truncated: true });
5252
});
5353

54+
test('snapshot digest keeps sparse fallback screenshot retrieval data', () => {
55+
const artifacts = [
56+
{
57+
field: 'fallbackScreenshotPath',
58+
artifactType: 'screenshot',
59+
path: '/tmp/snapshot-fallback.png',
60+
fileName: 'snapshot-fallback.png',
61+
},
62+
];
63+
const digest = snapshotView!(
64+
{
65+
nodes: [],
66+
truncated: false,
67+
snapshotQuality: { state: 'sparse', backend: 'private-ax' },
68+
warnings: ['Use screenshot as visual truth.'],
69+
fallbackScreenshotPath: '/tmp/snapshot-fallback.png',
70+
artifacts,
71+
},
72+
'digest',
73+
);
74+
75+
expect(digest).toMatchObject({
76+
fallbackScreenshotPath: '/tmp/snapshot-fallback.png',
77+
warnings: ['Use screenshot as visual truth.'],
78+
artifacts,
79+
});
80+
});
81+
5482
const overlayRef = (ref: string, label: string | undefined) => ({
5583
ref,
5684
...(label !== undefined ? { label } : {}),
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import path from 'node:path';
2+
import { expect, test, vi } from 'vitest';
3+
import type { SnapshotQualityVerdict } from '@agent-device/kernel/snapshot';
4+
import { makeIosSession, mkdtempForTest } from '../../__tests__/test-utils/index.ts';
5+
import { SessionStore } from '../session-store.ts';
6+
import { dispatchSnapshotViaRuntime } from '../snapshot-runtime.ts';
7+
8+
const dispatchCommandMock = vi.hoisted(() => vi.fn());
9+
10+
vi.mock('../../core/dispatch.ts', async (importOriginal) => {
11+
const actual = await importOriginal<typeof import('../../core/dispatch.ts')>();
12+
return {
13+
...actual,
14+
dispatchCommand: dispatchCommandMock,
15+
};
16+
});
17+
18+
const SPARSE: SnapshotQualityVerdict = {
19+
state: 'sparse',
20+
backend: 'private-ax',
21+
reason: 'snapshot returned no semantic controls or content',
22+
reasonCode: 'sparse-tree',
23+
};
24+
25+
async function scenario() {
26+
const root = await mkdtempForTest('agent-device-sparse-fallback-');
27+
const sessionStore = new SessionStore(path.join(root, 'sessions'));
28+
const sessionName = 'default';
29+
sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' }));
30+
return { sessionStore, sessionName, logPath: path.join(root, 'daemon.log') };
31+
}
32+
33+
/** Snapshot captures answer with the seeded verdict; screenshot captures answer per `screenshot`. */
34+
function seed(
35+
verdict: SnapshotQualityVerdict,
36+
screenshot: () => Promise<Record<string, unknown>> = async () => ({ width: 390, height: 844 }),
37+
) {
38+
dispatchCommandMock.mockReset();
39+
dispatchCommandMock.mockImplementation(async (_device: unknown, command: string) =>
40+
command === 'screenshot'
41+
? await screenshot()
42+
: {
43+
backend: 'xctest',
44+
truncated: false,
45+
quality: verdict,
46+
nodes: [{ index: 0, depth: 0, type: 'Application', label: 'Demo' }],
47+
},
48+
);
49+
}
50+
51+
async function dispatch(input: Awaited<ReturnType<typeof scenario>>, internalObservation = false) {
52+
const response = await dispatchSnapshotViaRuntime({
53+
req: {
54+
command: 'snapshot',
55+
positionals: [],
56+
token: 't',
57+
session: input.sessionName,
58+
...(internalObservation ? { internal: { observationOnly: true } } : {}),
59+
},
60+
sessionName: input.sessionName,
61+
logPath: input.logPath,
62+
sessionStore: input.sessionStore,
63+
});
64+
if (!response.ok) throw new Error('expected ok response');
65+
return response.data ?? {};
66+
}
67+
68+
function screenshotCalls() {
69+
return dispatchCommandMock.mock.calls.filter((call) => call[1] === 'screenshot');
70+
}
71+
72+
test('a sparse snapshot captures the screenshot its own remedy asks for and links it', async () => {
73+
const input = await scenario();
74+
seed(SPARSE);
75+
76+
const data = await dispatch(input);
77+
const warnings = (data.warnings ?? []) as string[];
78+
79+
expect(screenshotCalls()).toHaveLength(1);
80+
expect(data.fallbackScreenshotPath).toMatch(/\.png$/);
81+
expect(data.artifacts).toEqual([
82+
{
83+
field: 'fallbackScreenshotPath',
84+
artifactType: 'screenshot',
85+
path: data.fallbackScreenshotPath,
86+
fileName: 'snapshot-fallback.png',
87+
},
88+
]);
89+
// The verdict's own two lines still stand: the refs are invalid, and a screen that
90+
// publishes nothing is an app defect worth reporting.
91+
expect(warnings.some((line) => line.startsWith('No snapshot backend could read'))).toBe(true);
92+
expect(warnings.some((line) => line.includes('app accessibility bug'))).toBe(true);
93+
});
94+
95+
test('a readable snapshot never pays for a screenshot', async () => {
96+
const input = await scenario();
97+
seed({ state: 'healthy', backend: 'tree' });
98+
99+
const data = await dispatch(input);
100+
101+
expect(screenshotCalls()).toHaveLength(0);
102+
expect(data.fallbackScreenshotPath).toBeUndefined();
103+
expect(data.artifacts).toBeUndefined();
104+
});
105+
106+
test('internal observations stay silent so a polling wait cannot shoot once per poll', async () => {
107+
const input = await scenario();
108+
seed(SPARSE);
109+
110+
const data = await dispatch(input, true);
111+
112+
expect(screenshotCalls()).toHaveLength(0);
113+
expect(data.fallbackScreenshotPath).toBeUndefined();
114+
expect(data.artifacts).toBeUndefined();
115+
});
116+
117+
test('a failed fallback screenshot does not fail the snapshot that was asked for', async () => {
118+
const input = await scenario();
119+
seed(SPARSE, async () => {
120+
throw new Error('screenshot dispatch exploded');
121+
});
122+
123+
const data = await dispatch(input);
124+
const warnings = (data.warnings ?? []) as string[];
125+
126+
expect(screenshotCalls()).toHaveLength(1);
127+
expect(data.fallbackScreenshotPath).toBeUndefined();
128+
expect(data.artifacts).toBeUndefined();
129+
// The manual remedy is still on the response, so the caller is not left without one.
130+
expect(warnings.some((line) => line.includes('Use screenshot as visual truth'))).toBe(true);
131+
});

src/daemon/request-finalization.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,8 @@ function collectPendingArtifacts(req: DaemonRequest, data: DaemonResponseData):
128128
artifact &&
129129
typeof artifact.field === 'string' &&
130130
typeof artifact.path === 'string' &&
131-
typeof artifact.localPath === 'string' &&
132-
artifact.localPath.length > 0,
131+
((typeof artifact.localPath === 'string' && artifact.localPath.length > 0) ||
132+
(artifact.field === 'fallbackScreenshotPath' && artifact.artifactType === 'screenshot')),
133133
),
134134
);
135135
}

src/daemon/response-views.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ const DIGEST_REF_LIMIT = 12;
1616
/**
1717
* Token-cheap snapshot digest: the node count plus the first N actionable refs
1818
* (hittable and not occluded) with a label, and the cheap top-level signals
19-
* (`truncated`, `visibility`, `snapshotQuality`). The full node tree — the
19+
* (`truncated`, `visibility`, `snapshotQuality`) plus any fallback screenshot
20+
* retrieval handle. The full node tree — the
2021
* dominant token sink — is dropped. `full` returns today's shape unchanged
2122
* (nothing richer is computed yet).
2223
*/
@@ -33,6 +34,11 @@ function snapshotView(data: DaemonResponseData, level: ResponseLevel): DaemonRes
3334
truncated: data.truncated,
3435
...(data.visibility !== undefined ? { visibility: data.visibility } : {}),
3536
...(data.snapshotQuality !== undefined ? { snapshotQuality: data.snapshotQuality } : {}),
37+
...(data.warnings !== undefined ? { warnings: data.warnings } : {}),
38+
...(data.fallbackScreenshotPath !== undefined
39+
? { fallbackScreenshotPath: data.fallbackScreenshotPath }
40+
: {}),
41+
...(data.artifacts !== undefined ? { artifacts: data.artifacts } : {}),
3642
// #1076 versioned refs: the one-number generation is the pinning signal for
3743
// the refs above — cheap, and dropping it would strand auto-pinning clients.
3844
...(data.refsGeneration !== undefined ? { refsGeneration: data.refsGeneration } : {}),

src/daemon/snapshot-runtime.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
type CapturedSnapshotQuality,
2727
} from './snapshot-quality-latch.ts';
2828
import { createDaemonRuntimePolicy } from './runtime-policy.ts';
29+
import { captureSparseFallbackScreenshot } from './sparse-fallback-screenshot.ts';
2930
import { createDaemonRuntimeSessionStore } from './runtime-session.ts';
3031
import { getRequestSignal } from '../request/cancel.ts';
3132
import { isInteractiveObservation } from './session-action-recorder.ts';
@@ -53,19 +54,29 @@ export async function dispatchSnapshotViaRuntime(params: {
5354
customActions: req.flags?.snapshotCustomActions,
5455
forceFull: req.flags?.snapshotForceFull,
5556
});
56-
// #1076 versioned refs: the snapshot response is a ref-issuing response,
57-
// so it carries the stored tree's generation ONCE (`refsGeneration`) —
58-
// the node tree itself stays plain `e12` refs (token economy). The
59-
// capture above already stored the next session via setRecord, so the
60-
// store holds the generation these refs were minted from.
61-
const refsGeneration = publishedSnapshotGeneration(req, params.sessionStore.get(sessionName));
57+
const session = params.sessionStore.get(sessionName);
58+
const refsGeneration = publishedSnapshotGeneration(req, session);
6259
// ADR 0014: retain provenance in the immutable operational/ref-frame tree;
6360
// project only the published copy so settle and replay keep the full evidence.
6461
const publicNodes = stripAndroidSystemChromeProvenance(result.nodes);
6562
const publicResult =
6663
publicNodes === result.nodes ? result : { ...result, nodes: publicNodes };
64+
const fallbackScreenshot = await captureSparseFallbackScreenshot({
65+
req,
66+
session,
67+
sessionName,
68+
logPath: params.logPath,
69+
verdict: result.snapshotQuality,
70+
});
71+
const published = fallbackScreenshot
72+
? {
73+
...publicResult,
74+
fallbackScreenshotPath: fallbackScreenshot.path,
75+
artifacts: [fallbackScreenshot.artifact],
76+
}
77+
: publicResult;
6778
return {
68-
data: refsGeneration === undefined ? publicResult : { ...publicResult, refsGeneration },
79+
data: refsGeneration === undefined ? published : { ...published, refsGeneration },
6980
record: {
7081
kind: 'snapshot',
7182
nodes: result.nodes.length,

0 commit comments

Comments
 (0)