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
43 changes: 43 additions & 0 deletions docs/adr/0012-interactive-replay.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ Accepted (2026-07-10); partially implemented (last updated 2026-07-13). See [Mig
`REPLAY_DIVERGENCE` repair-loop fix (#1223).
- Decision 6, the base agent-supervised re-record repair — `replay --save-script` arming, the
post-watermark healed slice, `repairHint`, and the writer's bare-`@ref` fail-loud guard (#1228).
- Decision 4 amendment, `screen`'s capture scope and ref selection — the divergence `screen` capture runs
through the same `captureSnapshot` wrapper as plain `snapshot` (full-window scope + Android freshness /
post-action retry parity) under a clean, fixed capture-flags policy (a failed raw/scoped/`-d` action can
no longer narrow the diagnostic tree), with the chrome and meaningful-target filters layered on top as
filters, not scopings; and `screen.refs` is ranked within the byte cap (foreign-window dismiss targets
ahead of app content; mass-covered app nodes surfaced rather than emptied) instead of sliced in document
order, so a captured overlay is never buried past the cap (#1264).

**Accepted but NOT yet implemented** (this amendment; tracked by #1235 — repair-transaction lifecycle):
the R7 repair-transaction keep-alive and its distinct `resume.repairSessionHeld` signal, the ARMED →
Expand Down Expand Up @@ -438,6 +445,42 @@ healthy snapshot digest and the only form that issues actionable refs. `{ state:
hint }` is returned when capture fails or is sparse; it has no refs or generation and must not fall back to
the old session tree. Screen-capture failure never replaces or masks the original replay cause.

> **Amendment (#1264): `screen`'s capture scope and ref selection.** `refs` is a filtered, ranked digest
> of the exact same tree a plain `snapshot` would return at that moment. Two guarantees back the invariant:
>
> 1. **Capture scope.** The capture underneath `screen` runs through the **same `captureSnapshot` wrapper**
> the plain `snapshot` command's backend calls — not a parallel single-shot dispatch — so it inherits
> `snapshot`'s full-window scope (Android: the snapshot-helper route, with the existing graceful
> app-scoped fallback only when the helper is unavailable; iOS: the bounded system-modal probe path;
> macOS/Linux: their surface-scoped branches) AND its Android freshness / post-action retry policy. A
> divergence must not consume a first stale or app-scoped dump while a plain `snapshot` retries to the
> fresh full-window tree — that would make the divergence staler or narrower than `snapshot`, violating
> the invariant. The divergence capture's flags are a **clean, fixed policy** (full-window, non-raw,
> default depth), NOT the failed action's flags: a failed `snapshot --raw`/scoped/`-d` action must never
> narrow or reshape the diagnostic tree below what a plain `snapshot` shows, so `snapshotRaw` /
> `snapshotScope` / `snapshotDepth` are dropped; only the interactive-only policy (full for non-rect
> `get`/`is`/`wait` reads, interactive otherwise) is carried. The chrome filter (#1233/#1256,
> `collectSettleChromeRefs`) and the meaningful-target filter (label/id or `hittable`) are layered ON TOP
> of that full capture as **filters**, not as a separate, narrower scoping — a filter may drop a node the
> full capture contains, but the capture itself must never omit content `snapshot` would show.
> 2. **Ref selection within the cap.** The `screen.refs` cap is a **byte bound**, not a "first N in document
> order" policy. A separate-window overlay enumerates AFTER the app window's nodes, so a document-order
> slice truncates a fully-captured overlay away (its dismiss target sits past the cap) — reporting a
> healthy-looking app even though the capture holds the covering window. So `refs` is RANKED before it is
> capped: foreign-window (non-app-`bundleId`) hittable nodes — the dismiss targets for whatever covers
> the app — are promoted ahead of app content, with document order otherwise preserved (stable within
> each tier). And when a system overlay MASS-COVERS the app so every app node is annotated `covered`,
> those covered nodes are surfaced rather than emitting an empty `refs`: a report whose capture holds
> meaningful nodes but whose `refs` is empty is broken by construction.
>
> This is a hard invariant: **an agent must never see a healthier `screen` in a divergence report than a
> plain `snapshot` would show it.** Concretely, a separate-window system overlay covering the app at the
> moment of capture — a held volume dialog, a persistent quick-settings shade, a permission dialog — must
> appear in `screen.refs` (its actionable/hittable/labeled nodes surviving the filters AND the cap) exactly
> as `snapshot` would present it. `repairHint` (decision 6) and `suggestions` (decision 1) consume the FULL
> captured node list, not the capped `refs` slice, so hint routing is computed over the same full,
> correctly-scoped capture and is never routed as if the app underneath a covering overlay were healthy.

Response levels bound the entire serialized UTF-8 `details.divergence` object, not merely its arrays:
compact (`--level digest`) is at most **8 KiB**, default at most **24 KiB**, and full at most **64 KiB**.
Compact carries at most **8** screen refs and no suggestion entries — it carries `suggestionCount` (the
Expand Down
29 changes: 17 additions & 12 deletions src/core/__tests__/snapshot-chrome-android-statusbar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,14 +104,16 @@ test('Android non-raw capture: status-bar leaves are recognized as chrome once t
}
});

test('Android actionable systemui overlay (volume dialog) still survives with the status-bar leak fix (#1251)', () => {
// Extend the RAW capture with a disjoint systemui run whose leaf ids look
// like an actionable overlay (volume dialog): no real capture of that
// surface was available, but the point of this test is exactly that the
// new leaf-id set must NOT broaden to "any systemui id". Appended to the
// RAW array (not the already-walked output) so the whole tree — status bar
// AND volume dialog — goes through the real walk together, exactly as a
// single device capture would.
test('Android actionable systemui overlay (volume dialog) still survives the chrome filter (#1251)', () => {
// Filter-logic unit test, NOT a live-capture-path claim (#1264 finding 2): this
// exercises `collectSettleChromeRefs` in isolation over a synthetic systemui
// run appended to an unrelated capture fixture. The leaf ids ARE real,
// live-verified volume-dialog ids (`volume_dialog_container`,
// `volume_new_ringer_active_icon_container`), but appending them to this
// fixture does not reproduce how a live capture reaches the filter — that is
// covered separately by the full-capture invariant test below. The point
// here is narrower: the status-bar/nav-bar leaf-id set (#1251) must NOT
// broaden to "any systemui id" and drop an actionable overlay it is handed.
const rawWithVolumeDialog: RawSnapshotNode[] = [
...ANDROID_IME_CAPTURE_RAW_NODES,
{
Expand All @@ -125,22 +127,25 @@ test('Android actionable systemui overlay (volume dialog) still survives with th
parentIndex: 9000,
type: 'android.widget.ImageButton',
bundleId: 'com.android.systemui',
identifier: 'com.android.systemui:id/volume_dialog_slider',
label: 'Media volume',
identifier: 'com.android.systemui:id/volume_new_ringer_active_icon_container',
label: 'Ringer volume',
hittable: true,
},
];
const nodes = attachRefs(walkNonRawAndroidFixture(rawWithVolumeDialog));
const chromeRefs = collectSettleChromeRefs(nodes, 'com.callstack.agentdevicelab');

// The container is non-hittable with a generic id, but its hittable
// `volume_dialog_slider` child keeps it in the walked tree (descendantHittable).
// `volume_new_ringer_active_icon_container` child keeps it in the walked
// tree (descendantHittable).
assert.equal(
chromeRefs.has(refForIdentifier(nodes, 'com.android.systemui:id/volume_dialog_container')),
false,
);
assert.equal(
chromeRefs.has(refForIdentifier(nodes, 'com.android.systemui:id/volume_dialog_slider')),
chromeRefs.has(
refForIdentifier(nodes, 'com.android.systemui:id/volume_new_ringer_active_icon_container'),
),
false,
);
// The status-bar leak fix stays active in the very same tree.
Expand Down
Loading
Loading