Skip to content

Commit 529cb0f

Browse files
committed
fix: route divergence capture through captureSnapshot wrapper + clean flags policy
Completes the #1264 capture unification. The prior round routed captureDivergenceObservation through captureSnapshotData (the inner single-shot capture), but plain `snapshot`'s backend calls the HIGHER captureSnapshot wrapper, which owns Android freshness + post-action retry. A divergence could therefore consume the first stale/app-scoped dump while a plain `snapshot` retries to the fresh full-window tree — a divergence staler/narrower than `snapshot`, violating the invariant. - Route the divergence capture through the same `captureSnapshot` wrapper as plain snapshot, so it inherits freshness/post-action retry parity. No fork: the wrapper's params (device, session, flags, logPath) are all suppliable from the divergence path. - Build the divergence capture's flags from a clean, fixed policy (`divergenceCaptureFlags`: full-window, non-raw, default depth) instead of spreading the failed action's flags — so a failed `snapshot --raw`/scoped/`-d` action can no longer narrow the diagnostic tree. Only the interactive-only policy is carried (extracted as a helper so captureDivergenceObservation stays within complexity budget). Tests: a freshness-retry regression (session carries an active Android freshness marker; capture-1 is a stale near-empty dump that trips sharp-drop, capture-2 holds the overlay — asserts the divergence uses the retried fresh tree and dispatched twice), and a clean-flags regression (a failed raw/scoped/depth action — asserts the snapshot dispatch context drops snapshotRaw/scope/depth while still applying interactive-only). Both verified to fail on the pre-fix code. ADR-0012 decision 4 amendment updated to state the same-wrapper (freshness parity) and clean-flags guarantees. Live overlay acceptance remains a maintainer device step (env down): unit fixtures prove ref SELECTION after nodes are supplied, not that the Android helper returns the separate-window overlay at divergence time. Refs #1264
1 parent f83b9e6 commit 529cb0f

3 files changed

Lines changed: 232 additions & 26 deletions

File tree

‎docs/adr/0012-interactive-replay.md‎

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ Accepted (2026-07-10); partially implemented (last updated 2026-07-13). See [Mig
1212
`REPLAY_DIVERGENCE` repair-loop fix (#1223).
1313
- Decision 6, the base agent-supervised re-record repair — `replay --save-script` arming, the
1414
post-watermark healed slice, `repairHint`, and the writer's bare-`@ref` fail-loud guard (#1228).
15-
- Decision 4 amendment, `screen`'s capture scope and ref selection — the divergence `screen` capture is
16-
unified with `snapshot`'s own full-window scope (chrome and meaningful-target filters layered on top as
17-
filters, not scopings), and `screen.refs` is ranked within the byte cap (foreign-window dismiss targets
15+
- Decision 4 amendment, `screen`'s capture scope and ref selection — the divergence `screen` capture runs
16+
through the same `captureSnapshot` wrapper as plain `snapshot` (full-window scope + Android freshness /
17+
post-action retry parity) under a clean, fixed capture-flags policy (a failed raw/scoped/`-d` action can
18+
no longer narrow the diagnostic tree), with the chrome and meaningful-target filters layered on top as
19+
filters, not scopings; and `screen.refs` is ranked within the byte cap (foreign-window dismiss targets
1820
ahead of app content; mass-covered app nodes surfaced rather than emptied) instead of sliced in document
1921
order, so a captured overlay is never buried past the cap (#1264).
2022

@@ -446,14 +448,21 @@ the old session tree. Screen-capture failure never replaces or masks the origina
446448
> **Amendment (#1264): `screen`'s capture scope and ref selection.** `refs` is a filtered, ranked digest
447449
> of the exact same tree a plain `snapshot` would return at that moment. Two guarantees back the invariant:
448450
>
449-
> 1. **Capture scope.** The capture underneath `screen` is built with `snapshot`'s own full-window scope
450-
> (Android: the snapshot-helper route, with the existing graceful app-scoped fallback only when the
451-
> helper is unavailable; iOS: the same capture semantics as the bounded system-modal probe path;
452-
> macOS/Linux: their surface-scoped branches), never a narrower, app-window-only capture assembled
453-
> separately for divergence reporting. The chrome filter (#1233/#1256, `collectSettleChromeRefs`) and the
454-
> meaningful-target filter (label/id or `hittable`) are layered ON TOP of that full capture as
455-
> **filters**, not as a separate, narrower scoping — a filter may drop a node the full capture contains,
456-
> but the capture itself must never omit content `snapshot` would show.
451+
> 1. **Capture scope.** The capture underneath `screen` runs through the **same `captureSnapshot` wrapper**
452+
> the plain `snapshot` command's backend calls — not a parallel single-shot dispatch — so it inherits
453+
> `snapshot`'s full-window scope (Android: the snapshot-helper route, with the existing graceful
454+
> app-scoped fallback only when the helper is unavailable; iOS: the bounded system-modal probe path;
455+
> macOS/Linux: their surface-scoped branches) AND its Android freshness / post-action retry policy. A
456+
> divergence must not consume a first stale or app-scoped dump while a plain `snapshot` retries to the
457+
> fresh full-window tree — that would make the divergence staler or narrower than `snapshot`, violating
458+
> the invariant. The divergence capture's flags are a **clean, fixed policy** (full-window, non-raw,
459+
> default depth), NOT the failed action's flags: a failed `snapshot --raw`/scoped/`-d` action must never
460+
> narrow or reshape the diagnostic tree below what a plain `snapshot` shows, so `snapshotRaw` /
461+
> `snapshotScope` / `snapshotDepth` are dropped; only the interactive-only policy (full for non-rect
462+
> `get`/`is`/`wait` reads, interactive otherwise) is carried. The chrome filter (#1233/#1256,
463+
> `collectSettleChromeRefs`) and the meaningful-target filter (label/id or `hittable`) are layered ON TOP
464+
> of that full capture as **filters**, not as a separate, narrower scoping — a filter may drop a node the
465+
> full capture contains, but the capture itself must never omit content `snapshot` would show.
457466
> 2. **Ref selection within the cap.** The `screen.refs` cap is a **byte bound**, not a "first N in document
458467
> order" policy. A separate-window overlay enumerates AFTER the app window's nodes, so a document-order
459468
> slice truncates a fully-captured overlay away (its dismiss target sits past the cap) — reporting a

‎src/daemon/handlers/__tests__/session-replay-divergence.test.ts‎

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -815,3 +815,177 @@ test('buildReplayFailureDivergence: a mass-covered app with no actionable overla
815815
expect(screen.refs.length).toBeGreaterThan(0);
816816
expect(screen.refs.some((ref) => ref.label === 'Field A')).toBe(true);
817817
});
818+
819+
// #1264 (capture parity, point 1): the divergence capture must go through the
820+
// SAME `captureSnapshot` wrapper as a plain `snapshot`, so it inherits Android
821+
// freshness + post-action retry. Otherwise a divergence could consume the first
822+
// stale / app-scoped dump while a plain `snapshot` retries to the fresh
823+
// full-window tree — a divergence STALER than `snapshot`. Here the session
824+
// carries an active Android freshness marker (baselineCount 20); the first
825+
// on-device dump is a stale, near-empty tree (sharp node-count drop, no
826+
// meaningful content → the `sharp-drop` retry trigger), and only the RETRIED
827+
// second dump contains the system overlay. The divergence must reflect the
828+
// retried tree.
829+
test('buildReplayFailureDivergence: routes through the freshness-retry wrapper and uses the retried fresh tree, not the first stale dump (#1264 capture parity)', async () => {
830+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-divergence-fresh-'));
831+
const sessionStore = new SessionStore(path.join(root, 'sessions'));
832+
const sessionName = 'default';
833+
const appBundleId = 'com.callstack.agentdevicelab';
834+
const session = makeAndroidSession(sessionName, { appBundleId });
835+
// Active freshness marker: a navigation-sensitive action just ran, and the
836+
// pre-action baseline had 20 nodes, so a near-empty next dump is suspicious.
837+
session.androidSnapshotFreshness = {
838+
action: 'press',
839+
markedAt: Date.now(),
840+
baselineCount: 20,
841+
routeComparable: false,
842+
};
843+
sessionStore.set(sessionName, session);
844+
845+
// Capture 1: stale, near-empty dump (a single bare view — no hittable/label/
846+
// id) → `sharp-drop` vs the 20-node baseline → triggers a retry.
847+
const staleDump = {
848+
nodes: [
849+
{
850+
index: 0,
851+
type: 'android.view.View',
852+
bundleId: appBundleId,
853+
rect: { x: 0, y: 0, width: 10, height: 10 },
854+
},
855+
],
856+
truncated: false,
857+
backend: 'android',
858+
};
859+
// Capture 2: the fresh full-window tree, holding the app control AND the
860+
// separate-window system overlay's dismiss target.
861+
const freshDump = {
862+
nodes: [
863+
{
864+
index: 0,
865+
type: 'android.widget.Button',
866+
bundleId: appBundleId,
867+
label: 'App control',
868+
identifier: `${appBundleId}:id/control`,
869+
rect: { x: 20, y: 100, width: 200, height: 44 },
870+
hittable: true,
871+
},
872+
{
873+
index: 1,
874+
type: 'android.widget.ImageButton',
875+
bundleId: 'com.android.systemui',
876+
identifier: 'com.android.systemui:id/volume_new_ringer_active_icon_container',
877+
label: 'Ringer volume',
878+
rect: { x: 300, y: 300, width: 44, height: 44 },
879+
hittable: true,
880+
},
881+
],
882+
truncated: false,
883+
backend: 'android',
884+
};
885+
mockDispatchCommand.mockReset();
886+
mockDispatchCommand.mockResolvedValueOnce(staleDump).mockResolvedValueOnce(freshDump);
887+
888+
const action = {
889+
ts: 0,
890+
command: 'press',
891+
positionals: ['label="App control"'],
892+
flags: {},
893+
result: { selectorChain: ['label="App control"'] },
894+
};
895+
const divergence = await buildReplayFailureDivergence({
896+
error: { code: 'COMMAND_FAILED', message: 'not hittable' },
897+
action,
898+
index: 0,
899+
sourcePath: path.join(root, 'flow.ad'),
900+
sourceLine: 1,
901+
session: sessionStore.get(sessionName),
902+
sessionName,
903+
sessionStore,
904+
logPath: path.join(root, 'daemon.log'),
905+
responseLevel: 'default',
906+
planActions: [action],
907+
planDigest: 'test-plan-digest',
908+
});
909+
910+
// The freshness wrapper retried past the stale dump (2 on-device captures).
911+
expect(mockDispatchCommand).toHaveBeenCalledTimes(2);
912+
913+
expect(divergence.screen.state).toBe('available');
914+
const screen = divergence.screen as Extract<typeof divergence.screen, { state: 'available' }>;
915+
// The overlay only exists in the RETRIED capture, so its presence proves the
916+
// divergence used the fresh tree — parity with what a plain `snapshot` sees.
917+
expect(screen.refs.some((ref) => ref.label === 'Ringer volume')).toBe(true);
918+
expect(screen.refs.some((ref) => ref.label === 'App control')).toBe(true);
919+
});
920+
921+
// #1264 (clean flags policy, point 2): a failed `snapshot --raw`/scoped/`-d`
922+
// action must never narrow the DIAGNOSTIC divergence tree. The divergence
923+
// capture builds its flags from a fixed policy (full-window, non-raw, default
924+
// depth), NOT from the failed action's flags — so `snapshotRaw`/`snapshotScope`/
925+
// `snapshotDepth` on the action do not reach the capture. This inspects the
926+
// context handed to the snapshot dispatch and asserts those narrowing flags are
927+
// dropped while the interactive-only policy is still applied.
928+
test('buildReplayFailureDivergence: divergence capture drops the action snapshotRaw/scope/depth flags (#1264 clean flags policy)', async () => {
929+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-divergence-flags-'));
930+
const sessionStore = new SessionStore(path.join(root, 'sessions'));
931+
const sessionName = 'default';
932+
const appBundleId = 'com.callstack.agentdevicelab';
933+
sessionStore.set(sessionName, makeAndroidSession(sessionName, { appBundleId }));
934+
935+
mockDispatchCommand.mockReset();
936+
mockDispatchCommand.mockResolvedValue({
937+
nodes: [
938+
{
939+
index: 0,
940+
type: 'android.widget.Button',
941+
bundleId: appBundleId,
942+
label: 'Submit',
943+
identifier: `${appBundleId}:id/submit`,
944+
rect: { x: 20, y: 100, width: 200, height: 44 },
945+
hittable: true,
946+
},
947+
],
948+
truncated: false,
949+
backend: 'android',
950+
});
951+
952+
// A failed action that itself requested a raw, ref-scoped, depth-limited
953+
// snapshot — none of which may reshape the divergence diagnostic tree.
954+
const action = {
955+
ts: 0,
956+
command: 'press',
957+
positionals: ['label="Submit"'],
958+
flags: { snapshotRaw: true, snapshotScope: '@e5', snapshotDepth: 2 },
959+
result: { selectorChain: ['label="Submit"'] },
960+
};
961+
await buildReplayFailureDivergence({
962+
error: { code: 'COMMAND_FAILED', message: 'not hittable' },
963+
action,
964+
index: 0,
965+
sourcePath: path.join(root, 'flow.ad'),
966+
sourceLine: 1,
967+
session: sessionStore.get(sessionName),
968+
sessionName,
969+
sessionStore,
970+
logPath: path.join(root, 'daemon.log'),
971+
responseLevel: 'default',
972+
planActions: [action],
973+
planDigest: 'test-plan-digest',
974+
});
975+
976+
expect(mockDispatchCommand).toHaveBeenCalled();
977+
const context = mockDispatchCommand.mock.calls[0]?.[4] as
978+
| {
979+
snapshotRaw?: boolean;
980+
snapshotScope?: string;
981+
snapshotDepth?: number;
982+
snapshotInteractiveOnly?: boolean;
983+
}
984+
| undefined;
985+
// The action's narrowing flags are stripped by the fixed divergence policy.
986+
expect(context?.snapshotRaw).not.toBe(true);
987+
expect(context?.snapshotScope).toBeUndefined();
988+
expect(context?.snapshotDepth).toBeUndefined();
989+
// The interactive-only policy (press → interactive) is still applied.
990+
expect(context?.snapshotInteractiveOnly).toBe(true);
991+
});

‎src/daemon/handlers/session-replay-divergence.ts‎

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ import { markSessionSnapshotRefsIssued, setSessionSnapshot } from '../session-sn
44
import { isSparseSnapshotQualityVerdict } from '../../snapshot/snapshot-quality.ts';
55
import { displayLabel, formatRole } from '../../snapshot/snapshot-lines.ts';
66
import { redactDiagnosticData } from '../../kernel/redaction.ts';
7+
import type { CommandFlags } from '../../core/dispatch.ts';
78
import type { DaemonError, ResponseLevel } from '../../kernel/contracts.ts';
89
import type { SnapshotNode } from '../../kernel/snapshot.ts';
9-
import { buildSnapshotState, captureSnapshotData } from './snapshot-capture.ts';
10+
import { captureSnapshot } from './snapshot-capture.ts';
1011
import {
1112
buildSelectorChainForNode,
1213
resolveSelectorChain,
@@ -202,16 +203,28 @@ export function toReplayRepairHintCapture(
202203
* Sparse captures do not write back (selector-capture reliability contract),
203204
* so a sparse verdict degrades the whole observation.
204205
*
205-
* ADR 0012 decision 4 amendment (#1264): this reuses `captureSnapshotData` —
206-
* the SAME function the `snapshot` command itself builds its capture with
207-
* (Android: snapshot-helper full-window route with the graceful app-scoped
208-
* fallback; iOS: the bounded system-modal probe path; macOS/Linux: their
209-
* surface-scoped branches) — instead of a parallel hand-rolled dispatch. An
210-
* agent must never see a healthier `screen` in a divergence report than a
211-
* plain `snapshot` would show it, so `screen` cannot be built from a narrower
212-
* capture than `snapshot`'s. The chrome filter (`collectSettleChromeRefs`)
213-
* and the meaningful-target filter below stay layered ON TOP of this full
214-
* capture as FILTERS, never as a narrower scoping.
206+
* ADR 0012 decision 4 amendment (#1264): this routes through `captureSnapshot`
207+
* — the EXACT wrapper the `snapshot` command's backend calls
208+
* (`dispatchSnapshotViaRuntime` -> `createDaemonSnapshotBackend`), which owns
209+
* Android freshness + post-action retry (`capturePostActionAwareSnapshot`) on
210+
* top of the per-platform capture (Android snapshot-helper full-window route
211+
* with its graceful app-scoped fallback; iOS bounded system-modal probe path;
212+
* macOS/Linux surface-scoped branches). Calling the inner single-shot
213+
* `captureSnapshotData` instead would let a divergence consume a first stale /
214+
* app-scoped dump while a plain `snapshot` retries to the fresh full-window
215+
* tree — a divergence STALER or NARROWER than `snapshot`, which is exactly the
216+
* invariant this amendment forbids: an agent must never see a healthier
217+
* `screen` in a divergence report than a plain `snapshot` would show it.
218+
*
219+
* The capture flags are a CLEAN, fixed divergence-capture policy, NOT the
220+
* failed action's flags: `snapshotRaw`/`snapshotScope`/`snapshotDepth` from a
221+
* failed `snapshot --raw`/scoped/`-d` action would narrow or reshape the
222+
* diagnostic tree below what a plain `snapshot` shows, so they are dropped. The
223+
* only carried policy is interactive-only (`divergenceCaptureInteractiveOnly` —
224+
* full for non-rect `get`/`is`/`wait` reads so static-text targets survive,
225+
* interactive otherwise), matching heal's long-standing rule. The chrome filter
226+
* (`collectSettleChromeRefs`) and the meaningful-target filter stay layered ON
227+
* TOP of this full capture as FILTERS, never as a narrower scoping.
215228
*/
216229
export async function captureDivergenceObservation(params: {
217230
session: SessionState;
@@ -221,16 +234,15 @@ export async function captureDivergenceObservation(params: {
221234
action: SessionAction;
222235
}): Promise<DivergenceObservation> {
223236
const { session, sessionName, sessionStore, logPath, action } = params;
224-
const snapshotInteractiveOnly = divergenceCaptureInteractiveOnly(action);
225-
const flags = { ...(action.flags ?? {}), snapshotInteractiveOnly };
237+
const flags = divergenceCaptureFlags(action);
226238
try {
227-
const data = await captureSnapshotData({
239+
const capture = await captureSnapshot({
228240
device: session.device,
229241
session,
230242
flags,
231243
logPath,
232244
});
233-
const snapshot = buildSnapshotState(data, flags);
245+
const snapshot = capture.snapshot;
234246
if (isSparseSnapshotQualityVerdict(snapshot.snapshotQuality)) {
235247
return {
236248
state: 'unavailable',
@@ -256,6 +268,17 @@ export async function captureDivergenceObservation(params: {
256268
}
257269
}
258270

271+
/**
272+
* The clean, fixed flags for a divergence capture (#1264): full-window
273+
* (no `snapshotScope`), non-raw (no `snapshotRaw`), default depth (no
274+
* `snapshotDepth`) — a failed scoped/raw/depth-limited action must never
275+
* produce a narrowed divergence `screen`. Only the interactive-only policy is
276+
* carried, since it governs whether static-text suggestion targets survive.
277+
*/
278+
function divergenceCaptureFlags(action: SessionAction): CommandFlags {
279+
return { snapshotInteractiveOnly: divergenceCaptureInteractiveOnly(action) };
280+
}
281+
259282
/**
260283
* Interactive-only capture, except for non-rect selector reads
261284
* (`get`/`is`/`wait`) whose suggestion targets include static text — the

0 commit comments

Comments
 (0)