From 63681bddf06b9b82e11bbbfaf05fa2568fd7133c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 22:01:34 +0200 Subject: [PATCH 01/16] fix(replay): rank suggestions in the recorded scroll region first ADR 0012 requires equal-strength candidates in the recorded scroll region to precede candidates in other regions. Compare regions with the shared selector evidence owner after basis deduplication and before document order. Regression: divergence suggestions across regions fail on base 4c1c786968 (1 failed, 2 passed) and pass with the fix (3 passed). No recorded evidence retains document order. No live device run is required for diagnostic suggestion ranking. --- ...sion-replay-divergence-suggestions.test.ts | 65 +++++++++++++++++++ .../src/daemon-port/replay-backend-failure.ts | 1 + .../daemon-port/session-replay-divergence.ts | 8 ++- .../session-replay-suggestion-ranking.ts | 34 +++++++++- website/docs/docs/replay-e2e.md | 2 +- 5 files changed, 104 insertions(+), 6 deletions(-) diff --git a/packages/daemon/src/sessions/__tests__/replay-divergence/session-replay-divergence-suggestions.test.ts b/packages/daemon/src/sessions/__tests__/replay-divergence/session-replay-divergence-suggestions.test.ts index 86cd71791d..63e81ba48f 100644 --- a/packages/daemon/src/sessions/__tests__/replay-divergence/session-replay-divergence-suggestions.test.ts +++ b/packages/daemon/src/sessions/__tests__/replay-divergence/session-replay-divergence-suggestions.test.ts @@ -73,3 +73,68 @@ test('buildReplayFailureDivergence dedupes suggestions using the strongest basis expect(divergence.suggestions[0]?.ref).toBe('e1'); expect(divergence.suggestions[0]?.basis).toBe('id'); }); + +test.each([true, false])( + 'suggestions rank identity, recorded region, then document order (evidence: %s)', + async (recorded) => { + const root = mkdtempForTestSync('agent-device-replay-suggest-region-'); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + sessionStore.publish('default', makeIosSession('default', { appBundleId: 'com.example.app' })); + const button = (index: number, parentIndex: number, label: string) => ({ + index, + parentIndex, + depth: 1, + type: 'Button', + label, + rect: { x: 0, y: index * 10, width: 100, height: 44 }, + hittable: true, + }); + mockDispatchCommand.mockResolvedValue({ + nodes: [ + { index: 0, depth: 0, type: 'ScrollView', identifier: 'other' }, + button(1, 0, 'Other'), + { ...button(2, 0, 'Strong'), identifier: 'strong' }, + { index: 3, depth: 0, type: 'ScrollView', identifier: 'recorded' }, + button(4, 3, 'First'), + button(5, 3, 'Second'), + ], + truncated: false, + backend: 'xctest', + }); + const action = { + ts: 0, + command: 'click', + positionals: ['label="Other"'], + flags: {}, + result: { selectorChain: ['label="Second"', 'label="First"', 'id="strong"'] }, + ...(recorded + ? { + targetEvidence: { + role: 'button', + label: 'Original', + ancestry: [], + sibling: 0, + viewportOrder: 0, + scrollRegion: { role: 'scrollview', id: 'recorded' }, + verification: 'verified' as const, + }, + } + : {}), + }; + const divergence = await buildReplayFailureDivergence({ + error: { code: 'COMMAND_FAILED', message: 'not hittable' }, + action, + index: 0, + sourcePath: path.join(root, 'flow.ad'), + sourceLine: 1, + ...replayDivergenceForTest(sessionStore, 'default'), + logPath: path.join(root, 'daemon.log'), + responseLevel: 'full', + planActions: [action], + planDigest: 'test-plan-digest', + }); + expect(divergence.suggestions.map(({ label }) => label)).toEqual( + recorded ? ['Strong', 'First', 'Second', 'Other'] : ['Strong', 'Other', 'First', 'Second'], + ); + }, +); diff --git a/packages/replay-port/src/daemon-port/replay-backend-failure.ts b/packages/replay-port/src/daemon-port/replay-backend-failure.ts index d03bd2710d..7529de19c1 100644 --- a/packages/replay-port/src/daemon-port/replay-backend-failure.ts +++ b/packages/replay-port/src/daemon-port/replay-backend-failure.ts @@ -58,6 +58,7 @@ export async function buildReplayBackendActionFailureResponse( failure .suggestNodes({ createdAt: Date.now(), nodes: observation.nodes }) .map(({ node, basis }) => ({ node, nodeIndex: node.index, basis })), + { nodes: observation.nodes, targetEvidence: undefined }, ).map(({ node, basis }) => buildReplayDivergenceSuggestionForNode({ node, diff --git a/packages/replay-port/src/daemon-port/session-replay-divergence.ts b/packages/replay-port/src/daemon-port/session-replay-divergence.ts index 7878e59232..926f4f49da 100644 --- a/packages/replay-port/src/daemon-port/session-replay-divergence.ts +++ b/packages/replay-port/src/daemon-port/session-replay-divergence.ts @@ -508,8 +508,7 @@ function buildReplayDivergenceScreenRefs( /** * Decision 1's candidate machinery reused READ-ONLY over the shared capture. * Ranking: identity-component strength (id > role+label > label > other), - * then document order; the same-scrollRegion tier awaits decision 3's - * recorded evidence (migration step 4). + * then recorded scroll region, then document order. */ function collectReplayDivergenceSuggestions(params: { action: ReplayReportAction; @@ -576,7 +575,10 @@ function rankSuggestionCandidates(params: { if (!entry) continue; entries.push(entry); } - return rankAndDedupeReplaySuggestions(entries).map((entry) => entry.suggestion); + return rankAndDedupeReplaySuggestions(entries, { + nodes, + targetEvidence: action.targetEvidence, + }).map((entry) => entry.suggestion); } function resolveSuggestionCandidate(params: { diff --git a/packages/replay-port/src/daemon-port/session-replay-suggestion-ranking.ts b/packages/replay-port/src/daemon-port/session-replay-suggestion-ranking.ts index 3a20d53ebb..3136387e3d 100644 --- a/packages/replay-port/src/daemon-port/session-replay-suggestion-ranking.ts +++ b/packages/replay-port/src/daemon-port/session-replay-suggestion-ranking.ts @@ -1,3 +1,10 @@ +import type { TargetAnnotationV1 } from '@agent-device/contracts/replay'; +import type { SnapshotNode } from '@agent-device/kernel/snapshot'; +import { buildIndexMap } from '@agent-device/ad-script'; +import { + computeScrollRegionKey, + scrollRegionKeysEqual, +} from '@agent-device/selectors/target-evidence'; import type { ReplayDivergenceSuggestionBasis } from '@agent-device/contracts/divergence'; const BASIS_RANK: Record = { @@ -12,7 +19,13 @@ export function rankAndDedupeReplaySuggestions< readonly nodeIndex: number; readonly basis: ReplayDivergenceSuggestionBasis; }, ->(entries: Iterable): T[] { +>( + entries: Iterable, + context: { + nodes: readonly SnapshotNode[]; + targetEvidence: TargetAnnotationV1 | undefined; + }, +): T[] { const byNode = new Map(); for (const entry of entries) { const existing = byNode.get(entry.nodeIndex); @@ -20,9 +33,26 @@ export function rankAndDedupeReplaySuggestions< byNode.set(entry.nodeIndex, entry); } } + const sameRegion = new Set(); + if (context.targetEvidence) { + const byIndex = buildIndexMap(context.nodes); + for (const nodeIndex of byNode.keys()) { + const node = byIndex.get(nodeIndex); + if ( + node && + scrollRegionKeysEqual( + computeScrollRegionKey(node, byIndex), + context.targetEvidence.scrollRegion, + ) + ) + sameRegion.add(nodeIndex); + } + } return [...byNode.values()].sort( (left, right) => - basisRank(left.basis) - basisRank(right.basis) || left.nodeIndex - right.nodeIndex, + basisRank(left.basis) - basisRank(right.basis) || + Number(sameRegion.has(right.nodeIndex)) - Number(sameRegion.has(left.nodeIndex)) || + left.nodeIndex - right.nodeIndex, ); } diff --git a/website/docs/docs/replay-e2e.md b/website/docs/docs/replay-e2e.md index 497177f3dd..d086291f1e 100644 --- a/website/docs/docs/replay-e2e.md +++ b/website/docs/docs/replay-e2e.md @@ -324,7 +324,7 @@ A failing `replay`/`test` step returns a structured `REPLAY_DIVERGENCE` error. T - **`step`** — the 1-based plan index and its source file/line (through Maestro `runFlow` includes). - **`screen`** — a fresh post-failure snapshot digest with actionable refs, or `unavailable` with a reason/hint when capture failed or was sparse (never a stale tree). -- **`suggestions`** — up to 5 ranked, re-resolved candidates for the failing selector (id match ranks above role+label, which ranks above label-only), each with a `basis` you can inspect before acting. +- **`suggestions`** — up to 5 ranked, re-resolved candidates for the failing selector (id match ranks above role+label, which ranks above label-only), each with a `basis` you can inspect before acting. Equally strong candidates in the recorded scroll region rank first; document order breaks remaining ties. - **`resume`** — whether and how to continue without re-running the script from the top. ```jsonc From d3848ce90c601cda0ab43ca98da1d4e06a35248f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 22:05:53 +0200 Subject: [PATCH 02/16] fix(sdk): return structured malformed lease response errors ADR 0010 requires reachable SDK failures to use AppError with a stable code and machine-readable reason. Malformed daemon lease envelopes now throw COMMAND_FAILED with invalid-lease-response and structured response details. Regression: the public SDK allocate and heartbeat malformed-response matrix fails on base 4c1c786968 (8 failed, 4 passed) and passes with the fix (12 passed). Assertions also verify normalizeError preserves the code, reason, and response details. --- src/client/lease-client.test.ts | 29 ++++++++++++++++++++++++++++- src/client/lease-client.ts | 5 ++++- website/docs/docs/remote-proxy.md | 2 ++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/client/lease-client.test.ts b/src/client/lease-client.test.ts index 5156365970..28d59e3de1 100644 --- a/src/client/lease-client.test.ts +++ b/src/client/lease-client.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import { AppError } from '@agent-device/kernel/errors'; +import { AppError, normalizeError } from '@agent-device/kernel/errors'; import { createAgentDeviceClient } from '../agent-device-client.ts'; import { HUMAN_CONTROL_HOLD, HUMAN_CONTROL_SCOPE } from './human-control.fixtures.ts'; import type { DaemonRequest } from '@agent-device/kernel/contracts'; @@ -128,3 +128,30 @@ test('allocate sends the lease id named on the call, never the configured one', assert.equal(requests[1]?.meta?.leaseId, '0123456789abcdef'); assert.equal(lease.leaseId, 'abcdef0123456789'); }); + +for (const operation of ['allocate', 'heartbeat'] as const) { + for (const lease of [undefined, null, [], 'invalid']) { + test(`${operation} rejects a non-object lease (${JSON.stringify(lease)}) with structured diagnostics`, async () => { + const data = { lease }; + const client = createAgentDeviceClient( + {}, + { + transport: async () => ({ ok: true, data }), + }, + ); + await assert.rejects( + client.leases[operation]({ tenant: 'tenant-a', runId: 'run-a', leaseId: 'lease-a' }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'COMMAND_FAILED'); + assert.deepEqual(error.details, { reason: 'invalid-lease-response', response: data }); + const normalized = normalizeError(error); + assert.equal(normalized.code, 'COMMAND_FAILED'); + assert.equal(normalized.details?.reason, 'invalid-lease-response'); + assert.deepEqual(normalized.details?.response, data); + return true; + }, + ); + }); + } +} diff --git a/src/client/lease-client.ts b/src/client/lease-client.ts index bdb5ad0d87..65b307f96e 100644 --- a/src/client/lease-client.ts +++ b/src/client/lease-client.ts @@ -76,7 +76,10 @@ function normalizeHumanControlHold(value: unknown): HumanControlHold { function normalizeLease(data: Record): Lease { const rawLease = data.lease; if (!isRecord(rawLease)) { - throw new Error('Invalid lease response from daemon'); + throw new AppError('COMMAND_FAILED', 'Invalid lease response from daemon', { + reason: 'invalid-lease-response', + response: data, + }); } return { leaseId: readRequiredString(rawLease, 'leaseId'), diff --git a/website/docs/docs/remote-proxy.md b/website/docs/docs/remote-proxy.md index f95df6ef0f..71b2cd26b1 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -51,6 +51,8 @@ Without `--udid` or `--serial`, `open` does not pick the device on the agent mac `open` names each new lease with an id it makes (32 lowercase hex characters) and sends it with the allocation, so a host in front of the daemon knows the lease id before the daemon allocates. A direct `agent_device.lease.allocate` RPC can pass `leaseId` (16 to 128 hex characters) the same way; repeating the id of a live lease returns that lease when the retry carries the same tenant, run, client, lease backend, and provider, and either names no device or the device the lease binds, so a retry after a lost reply does not allocate a second one. A retry without `ttlMs` keeps the lease's window. A retry that changes any of those fields is refused like any other reused id. The id identifies the lease: one run can hold several leases. Any other id this daemon has already given a lease, active or ended, is refused with `INVALID_ARGS` and reason `LEASE_ID_IN_USE`, and without one the daemon makes the id. Use the id the reply carries: a run that already holds a lease gets that lease back, and an older daemon ignores the requested id. +If the daemon returns a missing or non-object lease, SDK `leases.allocate` and `leases.heartbeat` reject with `COMMAND_FAILED` and `details.reason: "invalid-lease-response"`. + Several agents can share one proxy when each follows the same `connect proxy`, `open`, commands, `close`, and `disconnect` flow. A busy device error means another agent holds the device until it closes its session or its lease expires. Do not put the proxy endpoint, token, tenant, or provider fields in `./agent-device.json`: repository From 759246f167a0e6d98a82f19b5f7e8c265869af77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 22:09:05 +0200 Subject: [PATCH 03/16] fix(daemon): import session command owners directly ADR 0033 permits barrels only at package boundaries. Remove the session lifecycle and observability re-export barrels and point production callers, test imports, and mocks at the existing implementation owners. Pin the same command exports at their owning modules. Keep private helper and reverse-handler boundaries enforced. Retire both barrel paths in R10. Neither deleted path has a Fallow baseline entry. Regression: direct owner and restored-barrel checks fail on base 4c1c786968 (2 failed) and pass with this fix (2 passed). Focused architecture coverage: 48 passed. Retained command-routing tests: 4 passed. No live device run is required because behavior is unchanged. --- .../__tests__/request-execution-scope.test.ts | 2 +- ...ion-replay-repair-record-exclusion.test.ts | 2 +- ...sion-replay-repair-transaction.fixtures.ts | 2 +- .../__tests__/session-device-claims.test.ts | 6 +- .../handlers/session-replay-command.ts | 2 +- .../daemon/src/sessions/handlers/session.ts | 20 +++--- .../__tests__/application.test.ts | 28 +++++--- .../src/sessions/session-lifecycle/index.ts | 6 -- .../__tests__/application.test.ts | 6 +- .../sessions/session-observability/index.ts | 2 - .../internal/__tests__/session-audio.test.ts | 2 +- .../__tests__/session-observability.test.ts | 2 +- .../__tests__/session-perf-runtime.test.ts | 2 +- scripts/layering/architecture-ownership.ts | 47 ++++++------ scripts/layering/daemon-modularity.test.ts | 72 +++++++++++++------ scripts/layering/daemon-modularity.ts | 3 +- 16 files changed, 118 insertions(+), 86 deletions(-) delete mode 100644 packages/daemon/src/sessions/session-lifecycle/index.ts delete mode 100644 packages/daemon/src/sessions/session-observability/index.ts diff --git a/packages/daemon/src/server/__tests__/request-execution-scope.test.ts b/packages/daemon/src/server/__tests__/request-execution-scope.test.ts index 007e13dfb8..38c0987e78 100644 --- a/packages/daemon/src/server/__tests__/request-execution-scope.test.ts +++ b/packages/daemon/src/server/__tests__/request-execution-scope.test.ts @@ -23,7 +23,7 @@ import { resolveSessionScope } from '../../sessions/session-routing.ts'; import type { DaemonRequest } from '../../core/daemon-request.ts'; import { mkdtempForTestSync } from '@agent-device/test-kit'; import { makeTestScreenRecordingResource } from '../../__tests__/support/screen-recording-live-handle.ts'; -import { handleSessionCloseCommands } from '../../sessions/session-lifecycle/index.ts'; +import { handleSessionCloseCommands } from '../../sessions/session-lifecycle/internal/session-close.ts'; const TEST_ROOT = mkdtempForTestSync('agent-device-request-execution-scope-'); const LOG_PATH = path.join(TEST_ROOT, 'diagnostics.log'); diff --git a/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-record-exclusion.test.ts b/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-record-exclusion.test.ts index 9787199b53..661387946c 100644 --- a/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-record-exclusion.test.ts +++ b/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-record-exclusion.test.ts @@ -45,7 +45,7 @@ vi.mock('../../../core/snapshot-interactor-capture.ts', () => ({ import fs from 'node:fs'; import path from 'node:path'; import { runReplayForTest } from '../replay-runtime/replay-command-fixture.ts'; -import { handleSessionCloseCommands as handleProductionCloseCommand } from '../../session-lifecycle/index.ts'; +import { handleSessionCloseCommands as handleProductionCloseCommand } from '../../session-lifecycle/internal/session-close.ts'; import { SessionStore } from '../../../core/session-store.ts'; import { LeaseRegistry } from '../../../resources/lease-registry.ts'; import { captureSnapshotWithInteractor } from '../../../core/snapshot-interactor-capture.ts'; diff --git a/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-transaction.fixtures.ts b/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-transaction.fixtures.ts index 88e1ea6c2e..30795c302c 100644 --- a/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-transaction.fixtures.ts +++ b/packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-transaction.fixtures.ts @@ -7,7 +7,7 @@ import { makeRepairCompleteSession, repairPublication, } from '../../../__tests__/support/session-factories.ts'; -import { handleSessionCloseCommands as handleProductionCloseCommand } from '../../session-lifecycle/index.ts'; +import { handleSessionCloseCommands as handleProductionCloseCommand } from '../../session-lifecycle/internal/session-close.ts'; import { bindLifecycleRuntime, inspectLifecycleRuntimeFacts, diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-device-claims.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-device-claims.test.ts index 1c7604a3a0..b39b9f0c65 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-device-claims.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-device-claims.test.ts @@ -51,10 +51,8 @@ import { } from '@agent-device/daemon-contracts/device-claims'; import { LeaseRegistry } from '../../../resources/lease-registry.ts'; import { SessionStore } from '../../../core/session-store.ts'; -import { - handleSessionCloseCommands as handleProductionCloseCommand, - handleSessionOpenCommands as handleProductionOpenCommand, -} from '../../session-lifecycle/index.ts'; +import { handleSessionCloseCommands as handleProductionCloseCommand } from '../../session-lifecycle/internal/session-close.ts'; +import { handleSessionOpenCommands as handleProductionOpenCommand } from '../../session-lifecycle/internal/session-open.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { makeAuthoringSession } from '../../../__tests__/support/session-factories.ts'; import { AppError } from '@agent-device/kernel/errors'; diff --git a/packages/daemon/src/sessions/handlers/session-replay-command.ts b/packages/daemon/src/sessions/handlers/session-replay-command.ts index 12833f230a..ee22ac21bf 100644 --- a/packages/daemon/src/sessions/handlers/session-replay-command.ts +++ b/packages/daemon/src/sessions/handlers/session-replay-command.ts @@ -26,7 +26,7 @@ import { assertSessionSelectorMatches } from '../../core/session-selector.ts'; import { resolveSessionScope } from '../session-routing.ts'; import { resolveEffectiveOpenRuntimeHints } from '../session-runtime.ts'; import { captureSnapshot } from '../../core/snapshot-capture.ts'; -import { handleSessionCloseCommands } from '../session-lifecycle/index.ts'; +import { handleSessionCloseCommands } from '../session-lifecycle/internal/session-close.ts'; import { onDelegatingLedger } from '../../core/request-ended-leases.ts'; import { createReplayTestVideoOwner } from './session-replay-video-owner.ts'; import type { SessionCommandHandler } from './session-command-input.ts'; diff --git a/packages/daemon/src/sessions/handlers/session.ts b/packages/daemon/src/sessions/handlers/session.ts index 94a8461f2d..176e226d55 100644 --- a/packages/daemon/src/sessions/handlers/session.ts +++ b/packages/daemon/src/sessions/handlers/session.ts @@ -7,15 +7,13 @@ import { } from '../../execution/handlers/session-selector-dispatch.ts'; import { handleSessionAppDeploymentCommand } from './session-app-deployment-route.ts'; import { runBatchCommands } from './session-batch.ts'; -import { - handleSessionCloseCommands, - handleSessionInventoryCommands, - handleSessionOpenCommands, -} from '../session-lifecycle/index.ts'; +import { handleSessionCloseCommands } from '../session-lifecycle/internal/session-close.ts'; +import { handleSessionInventoryCommands } from '../session-lifecycle/internal/inventory.ts'; +import { handleSessionOpenCommands } from '../session-lifecycle/internal/session-open.ts'; import { handleBootCommand } from './session-boot.ts'; import { handleShutdownCommand } from './session-shutdown.ts'; import { handleAppStateCommand } from './session-appstate.ts'; -import { handleSessionObservabilityCommands } from '../session-observability/index.ts'; +import { handleSessionObservabilityCommands } from '../session-observability/internal/session-observability.ts'; import { handleReplayCommand, handleReplayTestCommand } from './session-replay-command.ts'; import { handleSessionScriptPublication } from './session-script-publication.ts'; import { handleSessionClipboardCommand } from '../../execution/handlers/session-clipboard.ts'; @@ -26,12 +24,10 @@ import type { SessionCommandInput, SessionCommandParams, } from './session-command-input.ts'; -import type { - SessionCloseCommandInput, - SessionInventoryCommandInput, - SessionOpenCommandInput, -} from '../session-lifecycle/index.ts'; -import type { SessionObservabilityCommandInput } from '../session-observability/index.ts'; +import type { SessionCloseCommandInput } from '../session-lifecycle/internal/session-close.ts'; +import type { SessionInventoryCommandInput } from '../session-lifecycle/internal/inventory.ts'; +import type { SessionOpenCommandInput } from '../session-lifecycle/internal/session-open.ts'; +import type { SessionObservabilityCommandInput } from '../session-observability/internal/session-observability.ts'; import type { DescriptorSessionRouteCommandName } from '@agent-device/command-registry/registry'; import { LeaseRegistry } from '../../resources/lease-registry.ts'; diff --git a/packages/daemon/src/sessions/session-lifecycle/__tests__/application.test.ts b/packages/daemon/src/sessions/session-lifecycle/__tests__/application.test.ts index 98377819ed..b977f6a7c8 100644 --- a/packages/daemon/src/sessions/session-lifecycle/__tests__/application.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/__tests__/application.test.ts @@ -14,22 +14,28 @@ vi.mock('@agent-device/device-selection/device-inventory-context', async (import return { ...actual, listDeviceInventory: vi.fn(async () => []) }; }); -vi.mock('../index.ts', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../internal/session-close.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, handleSessionCloseCommands: vi.fn(actual.handleSessionCloseCommands) }; +}); + +vi.mock('../internal/inventory.ts', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, - handleSessionCloseCommands: vi.fn(actual.handleSessionCloseCommands), handleSessionInventoryCommands: vi.fn(actual.handleSessionInventoryCommands), - handleSessionOpenCommands: vi.fn(actual.handleSessionOpenCommands), }; }); +vi.mock('../internal/session-open.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, handleSessionOpenCommands: vi.fn(actual.handleSessionOpenCommands) }; +}); + import { handleSessionCommands } from '../../handlers/session.ts'; -import { - handleSessionCloseCommands, - handleSessionInventoryCommands, - handleSessionOpenCommands, -} from '../index.ts'; +import { handleSessionCloseCommands } from '../internal/session-close.ts'; +import { handleSessionInventoryCommands } from '../internal/inventory.ts'; +import { handleSessionOpenCommands } from '../internal/session-open.ts'; const mockHandleSessionCloseCommands = vi.mocked(handleSessionCloseCommands); const mockHandleSessionInventoryCommands = vi.mocked(handleSessionInventoryCommands); @@ -102,7 +108,7 @@ test('inventory commands retain their route responses and typed failures', async } }); -test('open routes only its lifecycle input through the public facade', async () => { +test('open routes only its lifecycle input through its owner', async () => { const expected: DaemonResponse = { ok: true, data: { session: 'default' } }; mockHandleSessionOpenCommands.mockImplementationOnce(async () => expected); @@ -129,7 +135,7 @@ test('open routes only its lifecycle input through the public facade', async () }); }); -test('close routes only its lifecycle input through the public facade', async () => { +test('close routes only its lifecycle input through its owner', async () => { const expected: DaemonResponse = { ok: true, data: { session: 'default' } }; mockHandleSessionCloseCommands.mockImplementationOnce(async () => expected); diff --git a/packages/daemon/src/sessions/session-lifecycle/index.ts b/packages/daemon/src/sessions/session-lifecycle/index.ts deleted file mode 100644 index 51914a9cfb..0000000000 --- a/packages/daemon/src/sessions/session-lifecycle/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { handleSessionCloseCommands } from './internal/session-close.ts'; -export type { SessionCloseCommandInput } from './internal/session-close.ts'; -export { handleSessionInventoryCommands } from './internal/inventory.ts'; -export type { SessionInventoryCommandInput } from './internal/inventory.ts'; -export { handleSessionOpenCommands } from './internal/session-open.ts'; -export type { SessionOpenCommandInput } from './internal/session-open.ts'; diff --git a/packages/daemon/src/sessions/session-observability/__tests__/application.test.ts b/packages/daemon/src/sessions/session-observability/__tests__/application.test.ts index acbd73af4d..858dca2149 100644 --- a/packages/daemon/src/sessions/session-observability/__tests__/application.test.ts +++ b/packages/daemon/src/sessions/session-observability/__tests__/application.test.ts @@ -5,8 +5,8 @@ import type { DaemonRequest } from '../../../core/daemon-request.ts'; import { makeSessionStore } from '../../../__tests__/support/store-factory.ts'; import { fakeCreateCommandSurface } from '../../../__tests__/support/command-surface-fake.ts'; -vi.mock('../index.ts', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('../internal/session-observability.ts', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, handleSessionObservabilityCommands: vi.fn(actual.handleSessionObservabilityCommands), @@ -14,7 +14,7 @@ vi.mock('../index.ts', async (importOriginal) => { }); import { handleSessionCommands } from '../../handlers/session.ts'; -import { handleSessionObservabilityCommands } from '../index.ts'; +import { handleSessionObservabilityCommands } from '../internal/session-observability.ts'; const mockHandleSessionObservabilityCommands = vi.mocked(handleSessionObservabilityCommands); diff --git a/packages/daemon/src/sessions/session-observability/index.ts b/packages/daemon/src/sessions/session-observability/index.ts deleted file mode 100644 index a584f4b975..0000000000 --- a/packages/daemon/src/sessions/session-observability/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { handleSessionObservabilityCommands } from './internal/session-observability.ts'; -export type { SessionObservabilityCommandInput } from './internal/session-observability.ts'; diff --git a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-audio.test.ts b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-audio.test.ts index c1f58a43bf..f4b69b5078 100644 --- a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-audio.test.ts +++ b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-audio.test.ts @@ -26,7 +26,7 @@ import { makeSessionStore } from '../../../../__tests__/support/store-factory.ts import { createAudioProbeAdmissionLedger } from '@agent-device/capture-kit/audio-probe-admission-ledger'; import { audioProbeDurableResource } from '@agent-device/capture-kit/audio-probe-session-resource'; import type { SessionStore } from '../../../../core/session-store.ts'; -import { handleSessionObservabilityCommands } from '../../index.ts'; +import { handleSessionObservabilityCommands } from '../session-observability.ts'; import { encodeHostAudioProbeDescriptor } from '@agent-device/capture-kit/audio-probe-descriptor-fixtures'; async function runAudio( diff --git a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-observability.test.ts b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-observability.test.ts index 02b6af5e6c..20852df66a 100644 --- a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-observability.test.ts +++ b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-observability.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { makeAndroidSession } from '../../../../__tests__/support/session-factories.ts'; import { makeSessionStore } from '../../../../__tests__/support/store-factory.ts'; -import { handleSessionObservabilityCommands } from '../../index.ts'; +import { handleSessionObservabilityCommands } from '../session-observability.ts'; import { createNetworkRuntime, emptyAppLogResult, diff --git a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-perf-runtime.test.ts b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-perf-runtime.test.ts index 9002f14e07..372f12de2a 100644 --- a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-perf-runtime.test.ts +++ b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-perf-runtime.test.ts @@ -25,7 +25,7 @@ import type { BindDeviceRuntime, InspectDeviceRuntimeFacts, } from '../../../../core/request-runtime-binding.ts'; -import { handleSessionObservabilityCommands } from '../../index.ts'; +import { handleSessionObservabilityCommands } from '../session-observability.ts'; const available = Object.freeze({ available: true as const }); const unavailable = Object.freeze({ diff --git a/scripts/layering/architecture-ownership.ts b/scripts/layering/architecture-ownership.ts index 93338ce70f..3f38d76b0e 100644 --- a/scripts/layering/architecture-ownership.ts +++ b/scripts/layering/architecture-ownership.ts @@ -21,27 +21,33 @@ export type LogicalModulePolicy = Readonly<{ */ forbiddenTargetShapes?: readonly ((file: string) => boolean)[]; internalForbiddenTargetShapes?: readonly ((file: string) => boolean)[]; - facade?: FacadeDeclaration; + facades?: readonly FacadeDeclaration[]; }>; -const DAEMON_SESSION_LIFECYCLE_FACADE = { - root: 'packages/daemon/src/sessions/session-lifecycle/index.ts', - exports: [ - 'SessionCloseCommandInput', - 'SessionInventoryCommandInput', - 'SessionOpenCommandInput', - 'handleSessionCloseCommands', - 'handleSessionInventoryCommands', - 'handleSessionOpenCommands', - ], -} as const; +const DAEMON_SESSION_LIFECYCLE_FACADES = [ + { + root: 'packages/daemon/src/sessions/session-lifecycle/internal/session-close.ts', + exports: ['SessionCloseCommandInput', 'handleSessionCloseCommands'], + }, + { + root: 'packages/daemon/src/sessions/session-lifecycle/internal/inventory.ts', + exports: ['SessionInventoryCommandInput', 'handleSessionInventoryCommands'], + }, + { + root: 'packages/daemon/src/sessions/session-lifecycle/internal/session-open.ts', + exports: ['SessionOpenCommandInput', 'handleSessionOpenCommands'], + }, +] as const; -const DAEMON_SESSION_OBSERVABILITY_FACADE = { - root: 'packages/daemon/src/sessions/session-observability/index.ts', - exports: ['SessionObservabilityCommandInput', 'handleSessionObservabilityCommands'], -} as const; +const DAEMON_SESSION_OBSERVABILITY_FACADES = [ + { + root: 'packages/daemon/src/sessions/session-observability/internal/session-observability.ts', + exports: ['SessionObservabilityCommandInput', 'handleSessionObservabilityCommands'], + }, +] as const; export const SESSION_LIFECYCLE_RETIRED_HANDLER_PATHS = [ + 'packages/daemon/src/sessions/session-lifecycle/index.ts', 'packages/daemon/src/handlers/session-device-utils.ts', 'packages/daemon/src/handlers/session-runtime-admission.ts', 'packages/daemon/src/handlers/session-open.ts', @@ -99,6 +105,7 @@ const DAEMON_INTERACTION_FACADE = { } as const; export const SESSION_OBSERVABILITY_RETIRED_HANDLER_PATHS = [ + 'packages/daemon/src/sessions/session-observability/index.ts', 'packages/daemon/src/handlers/session-observability.ts', 'packages/daemon/src/handlers/session-perf-runtime.ts', 'packages/daemon/src/handlers/session-network.ts', @@ -164,27 +171,27 @@ export const LOGICAL_MODULE_POLICIES = [ roots: ['packages/daemon/src/sessions/session-lifecycle/'], forbiddenTargetRoots: [], forbiddenTargetShapes: DAEMON_HANDLER_TARGET_SHAPES, - facade: DAEMON_SESSION_LIFECYCLE_FACADE, + facades: DAEMON_SESSION_LIFECYCLE_FACADES, }, { name: 'daemon-interaction', roots: ['packages/daemon/src/execution/interaction/'], forbiddenTargetRoots: [], internalForbiddenTargetShapes: DAEMON_HANDLER_TARGET_SHAPES, - facade: DAEMON_INTERACTION_FACADE, + facades: [DAEMON_INTERACTION_FACADE], }, { name: 'daemon-session-observability', roots: ['packages/daemon/src/sessions/session-observability/'], forbiddenTargetShapes: DAEMON_HANDLER_TARGET_SHAPES, - facade: DAEMON_SESSION_OBSERVABILITY_FACADE, + facades: DAEMON_SESSION_OBSERVABILITY_FACADES, }, ] as const satisfies readonly LogicalModulePolicy[]; export const ARCHITECTURE_OWNERSHIP = { logicalModules: LOGICAL_MODULE_POLICIES, facades: LOGICAL_MODULE_POLICIES.flatMap((module) => - module.facade ? [{ name: module.name, ...module.facade }] : [], + (module.facades ?? []).map((facade) => ({ name: module.name, ...facade })), ), vocabulary: [ { diff --git a/scripts/layering/daemon-modularity.test.ts b/scripts/layering/daemon-modularity.test.ts index 411b279be2..84a1024f75 100644 --- a/scripts/layering/daemon-modularity.test.ts +++ b/scripts/layering/daemon-modularity.test.ts @@ -197,7 +197,7 @@ test('replay-port rejects daemon source imports and engine back edges', () => { new Map([ [ 'packages/replay-port/src/daemon-port/test-command.ts', - "import { handleSessionCloseCommands } from '../../../../packages/daemon/src/sessions/session-lifecycle/index.ts';", + "import { handleSessionCloseCommands } from '../../../../packages/daemon/src/sessions/session-lifecycle/internal/session-close.ts';", ], [ 'packages/replay-port/src/daemon-port/native-command.ts', @@ -208,7 +208,7 @@ test('replay-port rejects daemon source imports and engine back edges', () => { "import { runReplayCommand } from '../../../replay-port/src/daemon-port/native-command.ts';", ], [ - 'packages/daemon/src/sessions/session-lifecycle/index.ts', + 'packages/daemon/src/sessions/session-lifecycle/internal/session-close.ts', 'export function handleSessionCloseCommands() {}', ], ['packages/daemon/src/core/session-store.ts', 'export class SessionStore {}'], @@ -226,7 +226,7 @@ test('replay-port rejects daemon source imports and engine back edges', () => { { file: 'packages/replay-port/src/daemon-port/test-command.ts', message: - 'replay-port must not import packages/daemon/src/sessions/session-lifecycle/index.ts', + 'replay-port must not import packages/daemon/src/sessions/session-lifecycle/internal/session-close.ts', }, { file: 'packages/replay-port/src/daemon-port/native-command.ts', @@ -240,25 +240,21 @@ test('replay-port rejects daemon source imports and engine back edges', () => { ); }); -test('session lifecycle rejects handler deep imports in both directions', () => { +test('session lifecycle rejects handler crossings and private helper imports', () => { const edges = resolveImportEdges( new Map([ [ 'packages/daemon/src/sessions/handlers/session.ts', - "import { handleSessionInventoryCommands } from '../session-lifecycle/internal/inventory.ts';\nexport function handleSessionCommands() {}", + "import { prepareOpenSession } from '../session-lifecycle/internal/session-open-prepare.ts';\nexport function handleSessionCommands() {}", ], [ - 'packages/daemon/src/sessions/session-lifecycle/internal/inventory.ts', - "import { handleSessionCommands } from '../../handlers/session.ts';\nexport function handleSessionInventoryCommands() {}", + 'packages/daemon/src/sessions/session-lifecycle/internal/session-open-prepare.ts', + "import { handleSessionCommands } from '../../handlers/session.ts';\nexport function prepareOpenSession() {}", ], [ 'packages/daemon/src/sessions/session-lifecycle/internal/session-close.ts', "import { handleSessionCommands } from '../../handlers/session.ts';", ], - [ - 'packages/daemon/src/sessions/session-lifecycle/index.ts', - 'export function handleSessionInventoryCommands() {}', - ], ]), ); @@ -278,10 +274,10 @@ test('session lifecycle rejects handler deep imports in both directions', () => file: 'packages/daemon/src/sessions/handlers/session.ts', line: 1, message: - "packages/daemon/src/sessions/handlers/session.ts must not import daemon-session-lifecycle's internal tree (packages/daemon/src/sessions/session-lifecycle/internal/inventory.ts)", + "packages/daemon/src/sessions/handlers/session.ts must not import daemon-session-lifecycle's internal tree (packages/daemon/src/sessions/session-lifecycle/internal/session-open-prepare.ts)", }, { - file: 'packages/daemon/src/sessions/session-lifecycle/internal/inventory.ts', + file: 'packages/daemon/src/sessions/session-lifecycle/internal/session-open-prepare.ts', line: 1, message: 'daemon-session-lifecycle must not import packages/daemon/src/sessions/handlers/session.ts', @@ -394,19 +390,19 @@ test('interaction rejects handler crossings and deep imports around its facade', ); }); -test('session observability rejects handler deep imports in both directions', () => { +test('session observability rejects handler crossings and private helper imports', () => { const edges = resolveImportEdges( new Map([ [ 'packages/daemon/src/sessions/handlers/session.ts', - "import { handleSessionObservabilityCommands } from '../session-observability/internal/session-observability.ts';\nexport function handleSessionCommands() {}", + "import { handleAudioCommand } from '../session-observability/internal/session-audio.ts';\nexport function handleSessionCommands() {}", ], [ - 'packages/daemon/src/sessions/session-observability/internal/session-observability.ts', - "import { handleSessionCommands } from '../../handlers/session.ts';\nexport function handleSessionObservabilityCommands() {}", + 'packages/daemon/src/sessions/session-observability/internal/session-audio.ts', + "import { handleSessionCommands } from '../../handlers/session.ts';\nexport function handleAudioCommand() {}", ], [ - 'packages/daemon/src/sessions/session-observability/index.ts', + 'packages/daemon/src/sessions/session-observability/internal/session-observability.ts', 'export function handleSessionObservabilityCommands() {}', ], ]), @@ -428,10 +424,10 @@ test('session observability rejects handler deep imports in both directions', () file: 'packages/daemon/src/sessions/handlers/session.ts', line: 1, message: - "packages/daemon/src/sessions/handlers/session.ts must not import daemon-session-observability's internal tree (packages/daemon/src/sessions/session-observability/internal/session-observability.ts)", + "packages/daemon/src/sessions/handlers/session.ts must not import daemon-session-observability's internal tree (packages/daemon/src/sessions/session-observability/internal/session-audio.ts)", }, { - file: 'packages/daemon/src/sessions/session-observability/internal/session-observability.ts', + file: 'packages/daemon/src/sessions/session-observability/internal/session-audio.ts', line: 1, message: 'daemon-session-observability must not import packages/daemon/src/sessions/handlers/session.ts', @@ -686,3 +682,39 @@ for (const file of [ }); } } + +test('session command owners accept direct imports without an internal barrel', () => { + const sources = new Map(); + for (const [family, owner, symbol] of [ + ['session-lifecycle', 'session-close', 'handleSessionCloseCommands'], + ['session-lifecycle', 'session-open', 'handleSessionOpenCommands'], + ['session-lifecycle', 'inventory', 'handleSessionInventoryCommands'], + ['session-observability', 'session-observability', 'handleSessionObservabilityCommands'], + ]) { + sources.set( + `packages/daemon/src/sessions/${family}/internal/${owner}.ts`, + `export function ${symbol}() {}`, + ); + sources.set( + `packages/daemon/src/sessions/handlers/${owner}.ts`, + `import { ${symbol} } from '../${family}/internal/${owner}.ts';`, + ); + } + assert.deepEqual( + checkDaemonModularityRatchets(resolveImportEdges(sources), REFERENCE, REFERENCE), + [], + ); +}); + +test('session command families reject restored internal barrels', () => { + const lifecycle = 'packages/daemon/src/sessions/session-lifecycle/index.ts'; + const observability = 'packages/daemon/src/sessions/session-observability/index.ts'; + assert.deepEqual( + checkRetiredSessionLifecyclePaths([lifecycle]).map(({ file }) => file), + [lifecycle], + ); + assert.deepEqual( + checkRetiredSessionObservabilityPaths([observability]).map(({ file }) => file), + [observability], + ); +}); diff --git a/scripts/layering/daemon-modularity.ts b/scripts/layering/daemon-modularity.ts index 204ac41d0c..5376b5fdee 100644 --- a/scripts/layering/daemon-modularity.ts +++ b/scripts/layering/daemon-modularity.ts @@ -252,7 +252,8 @@ function checkLogicalModuleImports(edges: readonly ResolvedImportEdge[]): Layeri if ( targetModule && sourceModule !== targetModule && - isInsideInternalTree(edge.target, targetModule.roots) + isInsideInternalTree(edge.target, targetModule.roots) && + !targetModule.facades?.some(({ root }) => edge.target === root) ) { violations.push({ rule: 'R10 daemon-modularity', From 9aca23654f4a9173d6c1c4b6cdefa0594e4b4e39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 22:13:25 +0200 Subject: [PATCH 04/16] fix(layering): align R75 guidance with its authority invariant ADR 0022 uses a membership ratchet with no approval input. Remove the nonexistent ADR-only growth approval path from both R75 diagnostics. Route reads and operations through an owning facade or semantic query; an intended boundary change must change its invariant and gate together. Regression: the existing shape/authority diagnostic test fails against base 4c1c786968 (1 failed) and passes with this fix (1 passed). The complete session-authority overlay suite passes all 10 tests. No live device run is required for tooling diagnostics. --- scripts/layering/session-authority-overlay.test.ts | 5 +++++ scripts/layering/session-authority-overlay.ts | 8 ++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/scripts/layering/session-authority-overlay.test.ts b/scripts/layering/session-authority-overlay.test.ts index 2af4e0a8dc..63d5e567a7 100644 --- a/scripts/layering/session-authority-overlay.test.ts +++ b/scripts/layering/session-authority-overlay.test.ts @@ -129,6 +129,11 @@ test('R75 fails a handler file that gains a shape or authority edge beyond the m assert.equal(authority.rule, SESSION_AUTHORITY_OVERLAY_RULE); assert.match(shape.message, /new handler-owned SessionState shape edge/); assert.match(authority.message, /new handler-owned SessionStore authority edge/); + for (const { message } of [shape, authority]) { + assert.match(message, /owning module's facade or a named semantic query/); + assert.match(message, /change the invariant and the gate together/); + assert.doesNotMatch(message, /accept the growth/); + } }); test('R75 passes when the handler-owned sets hold or shrink', () => { diff --git a/scripts/layering/session-authority-overlay.ts b/scripts/layering/session-authority-overlay.ts index 2225edab10..d2a85f6671 100644 --- a/scripts/layering/session-authority-overlay.ts +++ b/scripts/layering/session-authority-overlay.ts @@ -94,8 +94,8 @@ export function checkSessionAuthorityOverlay( line: 1, message: 'new handler-owned SessionState shape edge: the merge-base holds no such import. ' + - "Route the read through the owning module's facade or a named semantic query, or " + - "accept the growth at ADR 0022's authority overlay explicitly.", + "Route the read through the owning module's facade or a named semantic query. " + + 'If the boundary change is intended, change the invariant and the gate together.', }); } for (const file of scopedMeasured.authorityFiles) { @@ -106,8 +106,8 @@ export function checkSessionAuthorityOverlay( line: 1, message: 'new handler-owned SessionStore authority edge: the merge-base holds no such import. ' + - "Route the operation through the owning module's facade or a named store operation, " + - "or accept the growth at ADR 0022's authority overlay explicitly.", + "Route the operation through the owning module's facade or a named semantic query. " + + 'If the boundary change is intended, change the invariant and the gate together.', }); } return violations; From 62fe15982cf86b31a4dec43282a2c2ff18839d63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 22:27:40 +0200 Subject: [PATCH 05/16] fix(replay): rank regions only with verified evidence ADR 0012: only verified record-time evidence can select a scroll partition. Preserve the base order for unverifiable evidence and retain verified none-partition ranking. Proof on e984698a43 with updated tests: unverifiable evidence fails (1 failed, 4 passed); fixed suite: 5 passed. Removing region ranking separately fails the verified-none positive control. --- ...sion-replay-divergence-suggestions.test.ts | 24 +++++++++++++------ .../session-replay-suggestion-ranking.ts | 2 +- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/daemon/src/sessions/__tests__/replay-divergence/session-replay-divergence-suggestions.test.ts b/packages/daemon/src/sessions/__tests__/replay-divergence/session-replay-divergence-suggestions.test.ts index 63e81ba48f..75e431348d 100644 --- a/packages/daemon/src/sessions/__tests__/replay-divergence/session-replay-divergence-suggestions.test.ts +++ b/packages/daemon/src/sessions/__tests__/replay-divergence/session-replay-divergence-suggestions.test.ts @@ -74,9 +74,9 @@ test('buildReplayFailureDivergence dedupes suggestions using the strongest basis expect(divergence.suggestions[0]?.basis).toBe('id'); }); -test.each([true, false])( +test.each(['region', 'absent', 'none', 'unverifiable'] as const)( 'suggestions rank identity, recorded region, then document order (evidence: %s)', - async (recorded) => { + async (evidence) => { const root = mkdtempForTestSync('agent-device-replay-suggest-region-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); sessionStore.publish('default', makeIosSession('default', { appBundleId: 'com.example.app' })); @@ -97,6 +97,7 @@ test.each([true, false])( { index: 3, depth: 0, type: 'ScrollView', identifier: 'recorded' }, button(4, 3, 'First'), button(5, 3, 'Second'), + { ...button(6, 0, 'Outside'), parentIndex: undefined, depth: 0 }, ], truncated: false, backend: 'xctest', @@ -106,8 +107,10 @@ test.each([true, false])( command: 'click', positionals: ['label="Other"'], flags: {}, - result: { selectorChain: ['label="Second"', 'label="First"', 'id="strong"'] }, - ...(recorded + result: { + selectorChain: ['label="Second"', 'label="First"', 'id="strong"', 'label="Outside"'], + }, + ...(evidence !== 'absent' ? { targetEvidence: { role: 'button', @@ -115,8 +118,11 @@ test.each([true, false])( ancestry: [], sibling: 0, viewportOrder: 0, - scrollRegion: { role: 'scrollview', id: 'recorded' }, - verification: 'verified' as const, + ...(evidence === 'region' + ? { scrollRegion: { role: 'scrollview', id: 'recorded' } } + : {}), + verification: + evidence === 'unverifiable' ? ('unverifiable' as const) : ('verified' as const), }, } : {}), @@ -134,7 +140,11 @@ test.each([true, false])( planDigest: 'test-plan-digest', }); expect(divergence.suggestions.map(({ label }) => label)).toEqual( - recorded ? ['Strong', 'First', 'Second', 'Other'] : ['Strong', 'Other', 'First', 'Second'], + evidence === 'region' + ? ['Strong', 'First', 'Second', 'Other', 'Outside'] + : evidence === 'none' + ? ['Strong', 'Outside', 'Other', 'First', 'Second'] + : ['Strong', 'Other', 'First', 'Second', 'Outside'], ); }, ); diff --git a/packages/replay-port/src/daemon-port/session-replay-suggestion-ranking.ts b/packages/replay-port/src/daemon-port/session-replay-suggestion-ranking.ts index 3136387e3d..d106665533 100644 --- a/packages/replay-port/src/daemon-port/session-replay-suggestion-ranking.ts +++ b/packages/replay-port/src/daemon-port/session-replay-suggestion-ranking.ts @@ -34,7 +34,7 @@ export function rankAndDedupeReplaySuggestions< } } const sameRegion = new Set(); - if (context.targetEvidence) { + if (context.targetEvidence?.verification === 'verified') { const byIndex = buildIndexMap(context.nodes); for (const nodeIndex of byNode.keys()) { const node = byIndex.get(nodeIndex); From 49ca9e9b14917aa79e27fe93b5d5d725965fd754 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 22:27:50 +0200 Subject: [PATCH 06/16] fix(sdk): classify all malformed lease responses ADR 0010: use INVALID_LEASE_RESPONSE in the lease reason family for non-object payloads and invalid required leaseId, tenantId, runId, and backend fields. Retain response details through normalization. Regression proof on e984698a43: 40 failed, 4 passed; with the fix: 44 passed. Both allocate and heartbeat exercise non-object leases and invalid required fields. --- src/client/lease-client.test.ts | 20 ++++++++++++++++---- src/client/lease-client.ts | 16 ++++++++++------ 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/client/lease-client.test.ts b/src/client/lease-client.test.ts index 28d59e3de1..7907addb4a 100644 --- a/src/client/lease-client.test.ts +++ b/src/client/lease-client.test.ts @@ -130,8 +130,20 @@ test('allocate sends the lease id named on the call, never the configured one', }); for (const operation of ['allocate', 'heartbeat'] as const) { - for (const lease of [undefined, null, [], 'invalid']) { - test(`${operation} rejects a non-object lease (${JSON.stringify(lease)}) with structured diagnostics`, async () => { + const validLease = { + leaseId: 'lease-a', + tenantId: 'tenant-a', + runId: 'run-a', + backend: 'ios-instance', + }; + const invalidLeases: unknown[] = [undefined, null, [], 'invalid']; + for (const field of ['leaseId', 'tenantId', 'runId', 'backend'] as const) { + for (const value of [undefined, null, '', 42]) { + invalidLeases.push({ ...validLease, [field]: value }); + } + } + for (const lease of invalidLeases) { + test(`${operation} rejects an invalid lease (${JSON.stringify(lease)}) with structured diagnostics`, async () => { const data = { lease }; const client = createAgentDeviceClient( {}, @@ -144,10 +156,10 @@ for (const operation of ['allocate', 'heartbeat'] as const) { (error: unknown) => { assert.ok(error instanceof AppError); assert.equal(error.code, 'COMMAND_FAILED'); - assert.deepEqual(error.details, { reason: 'invalid-lease-response', response: data }); + assert.deepEqual(error.details, { reason: 'INVALID_LEASE_RESPONSE', response: data }); const normalized = normalizeError(error); assert.equal(normalized.code, 'COMMAND_FAILED'); - assert.equal(normalized.details?.reason, 'invalid-lease-response'); + assert.equal(normalized.details?.reason, 'INVALID_LEASE_RESPONSE'); assert.deepEqual(normalized.details?.response, data); return true; }, diff --git a/src/client/lease-client.ts b/src/client/lease-client.ts index 65b307f96e..2603546685 100644 --- a/src/client/lease-client.ts +++ b/src/client/lease-client.ts @@ -75,17 +75,21 @@ function normalizeHumanControlHold(value: unknown): HumanControlHold { function normalizeLease(data: Record): Lease { const rawLease = data.lease; - if (!isRecord(rawLease)) { + const leaseId = isRecord(rawLease) ? readOptionalString(rawLease, 'leaseId') : undefined; + const tenantId = isRecord(rawLease) ? readOptionalString(rawLease, 'tenantId') : undefined; + const runId = isRecord(rawLease) ? readOptionalString(rawLease, 'runId') : undefined; + const backend = isRecord(rawLease) ? readOptionalString(rawLease, 'backend') : undefined; + if (!isRecord(rawLease) || !leaseId || !tenantId || !runId || !backend) { throw new AppError('COMMAND_FAILED', 'Invalid lease response from daemon', { - reason: 'invalid-lease-response', + reason: 'INVALID_LEASE_RESPONSE', response: data, }); } return { - leaseId: readRequiredString(rawLease, 'leaseId'), - tenantId: readRequiredString(rawLease, 'tenantId'), - runId: readRequiredString(rawLease, 'runId'), - backend: readRequiredString(rawLease, 'backend') as Lease['backend'], + leaseId, + tenantId, + runId, + backend: backend as Lease['backend'], leaseProvider: readOptionalString(rawLease, 'leaseProvider'), clientId: readOptionalString(rawLease, 'clientId'), deviceKey: readOptionalString(rawLease, 'deviceKey'), From 42642cbcbdf9f55e0d25b23379c9641b24005040 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 22:28:16 +0200 Subject: [PATCH 07/16] docs(sdk): document malformed lease responses at the API ADR 0010: put INVALID_LEASE_RESPONSE beside the SDK lease API and remove the duplicate proxy mention. Document both non-object responses and invalid required lease fields. Validation: pnpm check:doc-prose and pnpm check:doc-links passed, including website build. No prose assertion tests added. --- website/docs/docs/client-api.md | 2 ++ website/docs/docs/remote-proxy.md | 2 -- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 7400b7a5e3..9c59cde521 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -300,6 +300,8 @@ Domain client methods: - `client.recording.record()` and `client.recording.trace()` - `client.settings.update()` +If the daemon returns a missing or non-object lease, or a lease without a non-empty string for `leaseId`, `tenantId`, `runId`, or `backend`, `client.leases.allocate()` and `client.leases.heartbeat()` reject with `COMMAND_FAILED` and `details.reason: "INVALID_LEASE_RESPONSE"`. + `client.devices.list()` returns `AgentDeviceDevice` entries. Their optional `model` and `osVersion` fields describe the hardware and OS when discovery reports them; see [Device discovery](/docs/commands#device-discovery) for the sources. `client.capture.snapshot()` carries an optional `viewport: { width, height }` beside `nodes`: the box those rects are measured in, in the same coordinate space and orientation. Scale and clip against it instead of inferring the screen from the largest rect. When no box was measured, the field is absent rather than zero; see [Coordinates and viewport](/docs/snapshots#coordinates-and-viewport) for what each producer answers with. diff --git a/website/docs/docs/remote-proxy.md b/website/docs/docs/remote-proxy.md index 71b2cd26b1..f95df6ef0f 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -51,8 +51,6 @@ Without `--udid` or `--serial`, `open` does not pick the device on the agent mac `open` names each new lease with an id it makes (32 lowercase hex characters) and sends it with the allocation, so a host in front of the daemon knows the lease id before the daemon allocates. A direct `agent_device.lease.allocate` RPC can pass `leaseId` (16 to 128 hex characters) the same way; repeating the id of a live lease returns that lease when the retry carries the same tenant, run, client, lease backend, and provider, and either names no device or the device the lease binds, so a retry after a lost reply does not allocate a second one. A retry without `ttlMs` keeps the lease's window. A retry that changes any of those fields is refused like any other reused id. The id identifies the lease: one run can hold several leases. Any other id this daemon has already given a lease, active or ended, is refused with `INVALID_ARGS` and reason `LEASE_ID_IN_USE`, and without one the daemon makes the id. Use the id the reply carries: a run that already holds a lease gets that lease back, and an older daemon ignores the requested id. -If the daemon returns a missing or non-object lease, SDK `leases.allocate` and `leases.heartbeat` reject with `COMMAND_FAILED` and `details.reason: "invalid-lease-response"`. - Several agents can share one proxy when each follows the same `connect proxy`, `open`, commands, `close`, and `disconnect` flow. A busy device error means another agent holds the device until it closes its session or its lease expires. Do not put the proxy endpoint, token, tenant, or provider fields in `./agent-device.json`: repository From bbbd14451f44264ce4e22a89be885cfe0c17017f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 22:28:17 +0200 Subject: [PATCH 08/16] fix(layering): guide authority edges to store operations ADR 0022: distinguish named store operations for SessionStore authority from semantic queries for SessionState shape reads. Pinned diagnostic proof on e984698a43: 1 failed, 9 passed; fixed suite: 10 passed. --- scripts/layering/session-authority-overlay.test.ts | 3 ++- scripts/layering/session-authority-overlay.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/layering/session-authority-overlay.test.ts b/scripts/layering/session-authority-overlay.test.ts index 63d5e567a7..eada05ec96 100644 --- a/scripts/layering/session-authority-overlay.test.ts +++ b/scripts/layering/session-authority-overlay.test.ts @@ -129,8 +129,9 @@ test('R75 fails a handler file that gains a shape or authority edge beyond the m assert.equal(authority.rule, SESSION_AUTHORITY_OVERLAY_RULE); assert.match(shape.message, /new handler-owned SessionState shape edge/); assert.match(authority.message, /new handler-owned SessionStore authority edge/); + assert.match(shape.message, /owning module's facade or a named semantic query/); + assert.match(authority.message, /owning module's facade or a named store operation/); for (const { message } of [shape, authority]) { - assert.match(message, /owning module's facade or a named semantic query/); assert.match(message, /change the invariant and the gate together/); assert.doesNotMatch(message, /accept the growth/); } diff --git a/scripts/layering/session-authority-overlay.ts b/scripts/layering/session-authority-overlay.ts index d2a85f6671..1e2f4f8b1f 100644 --- a/scripts/layering/session-authority-overlay.ts +++ b/scripts/layering/session-authority-overlay.ts @@ -106,7 +106,7 @@ export function checkSessionAuthorityOverlay( line: 1, message: 'new handler-owned SessionStore authority edge: the merge-base holds no such import. ' + - "Route the operation through the owning module's facade or a named semantic query. " + + "Route the operation through the owning module's facade or a named store operation. " + 'If the boundary change is intended, change the invariant and the gate together.', }); } From d971b3f875f9d593d86594e0e49263676f8d0ed1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 22:28:31 +0200 Subject: [PATCH 09/16] chore(gates): name retired session paths for their invariant ADR 0033: session retired-path lists include both former handler paths and deleted barrels. Rename those lists to RETIRED_PATHS and update consumers without changing enforcement or moving owners out of internal/. Focused architecture/modularity/overlay tests: 58 passed. Restoring both tracked barrels in a disposable checkout: 2 failed, 7 passed; guards retained after the rename. Internal owner placement remains a follow-up. --- scripts/layering/architecture-ownership.test.ts | 12 ++++++------ scripts/layering/architecture-ownership.ts | 4 ++-- scripts/layering/daemon-modularity.ts | 8 ++++---- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/scripts/layering/architecture-ownership.test.ts b/scripts/layering/architecture-ownership.test.ts index 0effdc9656..086631136e 100644 --- a/scripts/layering/architecture-ownership.test.ts +++ b/scripts/layering/architecture-ownership.test.ts @@ -6,8 +6,8 @@ import { ARCHITECTURE_OWNERSHIP, INTERACTION_RETIRED_HANDLER_PATHS, matchesDeclaredRoot, - SESSION_LIFECYCLE_RETIRED_HANDLER_PATHS, - SESSION_OBSERVABILITY_RETIRED_HANDLER_PATHS, + SESSION_LIFECYCLE_RETIRED_PATHS, + SESSION_OBSERVABILITY_RETIRED_PATHS, } from './architecture-ownership.ts'; import { readNamedExports } from './facade-exports.ts'; import { resolveImportEdges } from './model.ts'; @@ -61,16 +61,16 @@ test('logical module façades pin their named command surfaces', () => { } }); -test('session lifecycle retires its handler-owned helper paths', () => { +test('session lifecycle retires its handler helpers and barrel paths', () => { const tracked = new Set(listTrackedTypeScriptFiles(repoRoot)); - for (const retiredPath of SESSION_LIFECYCLE_RETIRED_HANDLER_PATHS) { + for (const retiredPath of SESSION_LIFECYCLE_RETIRED_PATHS) { assert.equal(tracked.has(retiredPath), false, `retired path was restored: ${retiredPath}`); } }); -test('session observability retires its handler-owned paths', () => { +test('session observability retires its handler and barrel paths', () => { const tracked = new Set(listTrackedTypeScriptFiles(repoRoot)); - for (const retiredPath of SESSION_OBSERVABILITY_RETIRED_HANDLER_PATHS) { + for (const retiredPath of SESSION_OBSERVABILITY_RETIRED_PATHS) { assert.equal(tracked.has(retiredPath), false, `retired path was restored: ${retiredPath}`); } }); diff --git a/scripts/layering/architecture-ownership.ts b/scripts/layering/architecture-ownership.ts index 3f38d76b0e..cd368e4687 100644 --- a/scripts/layering/architecture-ownership.ts +++ b/scripts/layering/architecture-ownership.ts @@ -46,7 +46,7 @@ const DAEMON_SESSION_OBSERVABILITY_FACADES = [ }, ] as const; -export const SESSION_LIFECYCLE_RETIRED_HANDLER_PATHS = [ +export const SESSION_LIFECYCLE_RETIRED_PATHS = [ 'packages/daemon/src/sessions/session-lifecycle/index.ts', 'packages/daemon/src/handlers/session-device-utils.ts', 'packages/daemon/src/handlers/session-runtime-admission.ts', @@ -104,7 +104,7 @@ const DAEMON_INTERACTION_FACADE = { ], } as const; -export const SESSION_OBSERVABILITY_RETIRED_HANDLER_PATHS = [ +export const SESSION_OBSERVABILITY_RETIRED_PATHS = [ 'packages/daemon/src/sessions/session-observability/index.ts', 'packages/daemon/src/handlers/session-observability.ts', 'packages/daemon/src/handlers/session-perf-runtime.ts', diff --git a/scripts/layering/daemon-modularity.ts b/scripts/layering/daemon-modularity.ts index 5376b5fdee..1dee838d6b 100644 --- a/scripts/layering/daemon-modularity.ts +++ b/scripts/layering/daemon-modularity.ts @@ -4,8 +4,8 @@ import { INTERACTION_RETIRED_HANDLER_PATHS, LOGICAL_MODULE_POLICIES, matchesDeclaredRoot, - SESSION_LIFECYCLE_RETIRED_HANDLER_PATHS, - SESSION_OBSERVABILITY_RETIRED_HANDLER_PATHS, + SESSION_LIFECYCLE_RETIRED_PATHS, + SESSION_OBSERVABILITY_RETIRED_PATHS, SNAPSHOT_EXECUTION_RETIRED_HANDLER_PATHS, type LogicalModulePolicy, } from './architecture-ownership.ts'; @@ -63,7 +63,7 @@ export function checkRetiredSessionLifecyclePaths( ): LayeringViolation[] { return checkRetiredHandlerPaths( sourceFiles, - SESSION_LIFECYCLE_RETIRED_HANDLER_PATHS, + SESSION_LIFECYCLE_RETIRED_PATHS, /^packages\/daemon\/src\/handlers\/session-(?:open|close)(?:-[^/]+)?\.ts$/, 'session lifecycle', ); @@ -74,7 +74,7 @@ export function checkRetiredSessionObservabilityPaths( ): LayeringViolation[] { return checkRetiredHandlerPaths( sourceFiles, - SESSION_OBSERVABILITY_RETIRED_HANDLER_PATHS, + SESSION_OBSERVABILITY_RETIRED_PATHS, /^packages\/daemon\/src\/handlers\/session-(?:observability|perf|logs|events|network|audio)(?:-[^/]+)?\.ts$/, 'session observability', ); From 21ac5c42463945db4ac36cbd4ebd8ae118693baf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 07:59:00 +0200 Subject: [PATCH 10/16] fix(replay): load region ranking helpers on demand Fix the eager-closure failure in Coverage: the ranking facade must not eagerly load the ad-script and selector graphs. Await ranking at both production response seams and cover Maestro ranking without target-v1 evidence. Share scroll-region membership across ranking, classification, recording, and repair hints. Review: https://github.com/callstack/agent-device/pull/3762#discussion_r4298986816 Review: https://github.com/callstack/agent-device/pull/3762#discussion_r4298986809 --- .../session-replay-maestro-failure.test.ts | 59 +++++++++++++++++++ .../src/daemon-port/repair-hint.ts | 7 +-- .../src/daemon-port/replay-backend-failure.ts | 12 ++-- .../daemon-port/session-replay-divergence.ts | 15 ++--- .../session-replay-suggestion-ranking.ts | 19 ++---- .../src/daemon-port/target-classification.ts | 5 +- packages/selectors/src/target-evidence.ts | 12 +++- 7 files changed, 92 insertions(+), 37 deletions(-) diff --git a/packages/daemon/src/sessions/__tests__/replay-maestro/session-replay-maestro-failure.test.ts b/packages/daemon/src/sessions/__tests__/replay-maestro/session-replay-maestro-failure.test.ts index efec07e900..7812c140a4 100644 --- a/packages/daemon/src/sessions/__tests__/replay-maestro/session-replay-maestro-failure.test.ts +++ b/packages/daemon/src/sessions/__tests__/replay-maestro/session-replay-maestro-failure.test.ts @@ -443,6 +443,65 @@ test('typed Maestro text matching an identifier reports id basis', async () => { expect(divergence.suggestions).toEqual([expect.objectContaining({ basis: 'id' })]); }); +test('typed Maestro failure ranks by identity and document order across scroll regions', async () => { + const command = { + kind: 'tapOn' as const, + source: { path: '/flows/actions.yaml', line: 4 }, + target: { space: 'target' as const, selector: { text: 'save.*' } }, + } satisfies Extract; + const response = await buildFailureResponse(command, [ + { + index: 0, + type: 'Application', + rect: { x: 0, y: 0, width: 402, height: 874 }, + }, + { + index: 1, + parentIndex: 0, + identifier: 'actions', + type: 'ScrollView', + rect: { x: 0, y: 0, width: 402, height: 300 }, + }, + { + index: 2, + parentIndex: 1, + type: 'Button', + label: 'Save in list', + rect: { x: 16, y: 40, width: 140, height: 44 }, + hittable: true, + }, + { + index: 3, + parentIndex: 0, + type: 'Button', + label: 'Save outside', + rect: { x: 16, y: 350, width: 140, height: 44 }, + hittable: true, + }, + { + index: 4, + parentIndex: 0, + type: 'Button', + identifier: 'save-primary', + label: 'Confirm', + rect: { x: 16, y: 410, width: 140, height: 44 }, + hittable: true, + }, + ]); + const divergence = response.error.details?.divergence as { + screen: { state: string }; + suggestions: Array<{ basis: string; label: string }>; + }; + + expect(response.error.code).toBe('REPLAY_DIVERGENCE'); + expect(divergence.screen.state).toBe('available'); + expect(divergence.suggestions.map(({ basis, label }) => [basis, label])).toEqual([ + ['id', 'Confirm'], + ['label', 'Save in list'], + ['label', 'Save outside'], + ]); +}); + test('typed Maestro suggestions retain total count before the five-entry cap', async () => { const command = { kind: 'tapOn' as const, diff --git a/packages/replay-port/src/daemon-port/repair-hint.ts b/packages/replay-port/src/daemon-port/repair-hint.ts index bfddf9bc21..fac133d327 100644 --- a/packages/replay-port/src/daemon-port/repair-hint.ts +++ b/packages/replay-port/src/daemon-port/repair-hint.ts @@ -23,10 +23,7 @@ import type { SnapshotNode } from '@agent-device/kernel/snapshot'; import type { ReplayDivergenceKind, ReplayRepairHint } from '@agent-device/contracts/divergence'; import { matchesAncestryPrefix, buildAncestryChain, buildIndexMap } from '@agent-device/ad-script'; import type { TargetAnnotationV1, TargetScrollRegion } from '@agent-device/contracts/replay'; -import { - computeScrollRegionKey, - scrollRegionKeysEqual, -} from '@agent-device/selectors/target-evidence'; +import { isInScrollRegion } from '@agent-device/selectors/target-evidence'; export type ReplayRepairHintCapture = | { state: 'available'; nodes: SnapshotNode[] } @@ -107,7 +104,7 @@ function isScrollRegionPresent( // Some node's OWN nearest scrollable ancestor (walked via parentIndex) // resolves to the recorded region: the identified region still contains a // descendant. - return nodes.some((node) => scrollRegionKeysEqual(computeScrollRegionKey(node, byIndex), region)); + return nodes.some((node) => isInScrollRegion(node, byIndex, region)); } /** diff --git a/packages/replay-port/src/daemon-port/replay-backend-failure.ts b/packages/replay-port/src/daemon-port/replay-backend-failure.ts index 7529de19c1..a5581099b7 100644 --- a/packages/replay-port/src/daemon-port/replay-backend-failure.ts +++ b/packages/replay-port/src/daemon-port/replay-backend-failure.ts @@ -54,11 +54,13 @@ export async function buildReplayBackendActionFailureResponse( }; const suggestions = session && observation.state === 'available' && !failure.isControl - ? rankAndDedupeReplaySuggestions( - failure - .suggestNodes({ createdAt: Date.now(), nodes: observation.nodes }) - .map(({ node, basis }) => ({ node, nodeIndex: node.index, basis })), - { nodes: observation.nodes, targetEvidence: undefined }, + ? ( + await rankAndDedupeReplaySuggestions( + failure + .suggestNodes({ createdAt: Date.now(), nodes: observation.nodes }) + .map(({ node, basis }) => ({ node, nodeIndex: node.index, basis })), + { nodes: observation.nodes, targetEvidence: undefined }, + ) ).map(({ node, basis }) => buildReplayDivergenceSuggestionForNode({ node, diff --git a/packages/replay-port/src/daemon-port/session-replay-divergence.ts b/packages/replay-port/src/daemon-port/session-replay-divergence.ts index 926f4f49da..f43b874469 100644 --- a/packages/replay-port/src/daemon-port/session-replay-divergence.ts +++ b/packages/replay-port/src/daemon-port/session-replay-divergence.ts @@ -112,7 +112,7 @@ export async function buildReplayFailureDivergence(params: { const screen = buildDivergenceScreen(observation, sanitize); const suggestions = observation.state === 'available' && session - ? collectReplayDivergenceSuggestions({ + ? await collectReplayDivergenceSuggestions({ action, session, nodes: observation.nodes, @@ -510,12 +510,12 @@ function buildReplayDivergenceScreenRefs( * Ranking: identity-component strength (id > role+label > label > other), * then recorded scroll region, then document order. */ -function collectReplayDivergenceSuggestions(params: { +async function collectReplayDivergenceSuggestions(params: { action: ReplayReportAction; session: ReplaySessionState; nodes: SnapshotNode[]; sanitize: DivergenceFieldSanitizer; -}): ReplayDivergenceSuggestion[] { +}): Promise { const { action, session, nodes, sanitize } = params; if (!isSuggestionEligibleCommand(action.command)) return []; const candidates = collectReplaySelectorCandidates(action); @@ -549,14 +549,14 @@ type RankedSuggestion = { nodeIndex: number; }; -function rankSuggestionCandidates(params: { +async function rankSuggestionCandidates(params: { candidates: string[]; nodes: SnapshotNode[]; session: ReplaySessionState; action: ReplayReportAction; matching: SuggestionMatchingConfig; sanitize: DivergenceFieldSanitizer; -}): ReplayDivergenceSuggestion[] { +}): Promise { const { candidates, nodes, session, action, matching, sanitize } = params; // Dedupe by node (its unique tree index), keeping the STRONGEST match basis // per the ADR: a node reachable through several recorded selector terms @@ -575,10 +575,11 @@ function rankSuggestionCandidates(params: { if (!entry) continue; entries.push(entry); } - return rankAndDedupeReplaySuggestions(entries, { + const ranked = await rankAndDedupeReplaySuggestions(entries, { nodes, targetEvidence: action.targetEvidence, - }).map((entry) => entry.suggestion); + }); + return ranked.map((entry) => entry.suggestion); } function resolveSuggestionCandidate(params: { diff --git a/packages/replay-port/src/daemon-port/session-replay-suggestion-ranking.ts b/packages/replay-port/src/daemon-port/session-replay-suggestion-ranking.ts index d106665533..a3364ca906 100644 --- a/packages/replay-port/src/daemon-port/session-replay-suggestion-ranking.ts +++ b/packages/replay-port/src/daemon-port/session-replay-suggestion-ranking.ts @@ -1,10 +1,5 @@ import type { TargetAnnotationV1 } from '@agent-device/contracts/replay'; import type { SnapshotNode } from '@agent-device/kernel/snapshot'; -import { buildIndexMap } from '@agent-device/ad-script'; -import { - computeScrollRegionKey, - scrollRegionKeysEqual, -} from '@agent-device/selectors/target-evidence'; import type { ReplayDivergenceSuggestionBasis } from '@agent-device/contracts/divergence'; const BASIS_RANK: Record = { @@ -14,7 +9,7 @@ const BASIS_RANK: Record = { other: 3, }; -export function rankAndDedupeReplaySuggestions< +export async function rankAndDedupeReplaySuggestions< T extends { readonly nodeIndex: number; readonly basis: ReplayDivergenceSuggestionBasis; @@ -25,7 +20,7 @@ export function rankAndDedupeReplaySuggestions< nodes: readonly SnapshotNode[]; targetEvidence: TargetAnnotationV1 | undefined; }, -): T[] { +): Promise { const byNode = new Map(); for (const entry of entries) { const existing = byNode.get(entry.nodeIndex); @@ -35,16 +30,12 @@ export function rankAndDedupeReplaySuggestions< } const sameRegion = new Set(); if (context.targetEvidence?.verification === 'verified') { + const { buildIndexMap } = await import('@agent-device/ad-script'); + const { isInScrollRegion } = await import('@agent-device/selectors/target-evidence'); const byIndex = buildIndexMap(context.nodes); for (const nodeIndex of byNode.keys()) { const node = byIndex.get(nodeIndex); - if ( - node && - scrollRegionKeysEqual( - computeScrollRegionKey(node, byIndex), - context.targetEvidence.scrollRegion, - ) - ) + if (node && isInScrollRegion(node, byIndex, context.targetEvidence.scrollRegion)) sameRegion.add(nodeIndex); } } diff --git a/packages/replay-port/src/daemon-port/target-classification.ts b/packages/replay-port/src/daemon-port/target-classification.ts index 641afd0a7e..8073a916d7 100644 --- a/packages/replay-port/src/daemon-port/target-classification.ts +++ b/packages/replay-port/src/daemon-port/target-classification.ts @@ -51,8 +51,7 @@ import { import { boundedLocalIdentity, computeSiblingOrdinal, - computeScrollRegionKey, - scrollRegionKeysEqual, + isInScrollRegion, orderByViewportPosition, } from '@agent-device/selectors/target-evidence'; import { resolveRecordedTarget } from '@agent-device/selectors'; @@ -122,7 +121,7 @@ export function classifyReplayTarget(params: { (candidate) => computeSiblingOrdinal(nodes, candidate) === recorded.sibling, ); const regionMembers = identitySet.filter((candidate) => - scrollRegionKeysEqual(computeScrollRegionKey(candidate, byIndex), recorded.scrollRegion), + isInScrollRegion(candidate, byIndex, recorded.scrollRegion), ); const orderedRegion = orderByViewportPosition(regionMembers); const viewportCandidateRef = orderedRegion[recorded.viewportOrder]?.ref; diff --git a/packages/selectors/src/target-evidence.ts b/packages/selectors/src/target-evidence.ts index a1a35810b8..549d3281c1 100644 --- a/packages/selectors/src/target-evidence.ts +++ b/packages/selectors/src/target-evidence.ts @@ -208,6 +208,14 @@ export function scrollRegionKeysEqual( return matchesLocalIdentity(a, b); } +export function isInScrollRegion( + node: SnapshotNode, + byIndex: Map, + region: TargetScrollRegion | undefined, +): boolean { + return scrollRegionKeysEqual(computeScrollRegionKey(node, byIndex), region); +} + function boundedRect(node: SnapshotNode): TargetAnnotationV1['rect'] { const rect = node.rect; if (!rect) return undefined; @@ -247,9 +255,7 @@ function computeDisambiguationDomain(params: { const regionMembers = identitySet.length > 0 - ? identitySet.filter((candidate) => - scrollRegionKeysEqual(computeScrollRegionKey(candidate, byIndex), scrollRegion), - ) + ? identitySet.filter((candidate) => isInScrollRegion(candidate, byIndex, scrollRegion)) : undefined; const orderedRegion = regionMembers ? orderByViewportPosition(regionMembers) : []; From c1527d8845e38599c62fb03590d6cf238ca2aa1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 07:59:00 +0200 Subject: [PATCH 11/16] docs(replay): qualify the region tie-break with verified evidence The maintainer requires both ADR 0012 passages and the user documentation to match the verified-only target-v1 contract before merge. Review: https://github.com/callstack/agent-device/pull/3762#discussion_r4299087807 --- docs/adr/0012-interactive-replay.md | 4 ++-- website/docs/docs/replay-e2e.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/adr/0012-interactive-replay.md b/docs/adr/0012-interactive-replay.md index 3b30ec85a0..8362cac3be 100644 --- a/docs/adr/0012-interactive-replay.md +++ b/docs/adr/0012-interactive-replay.md @@ -23,7 +23,7 @@ The registries and tests named there are the living source of truth. 1. **`--update` never rewrites.** The candidate machinery of heal only ranks the bounded `suggestions` list inside a divergence report. - The order is identity components, then the same `scrollRegion`, then document order. + The order is identity components, then the same `scrollRegion` for a verified `target-v1` annotation, then document order. The list is deduplicated by node. No unattended repair path exists. 2. **Every element resolution discloses how it resolved.** @@ -98,7 +98,7 @@ They do not act unattended. 1. Candidates that satisfy more identity components rank first. A recorded-id match precedes a role+label match. A role+label match precedes a label-only match. -2. Among equal candidates, candidates in the same `scrollRegion` as recorded rank before candidates in other regions. +2. For a verified `target-v1` annotation, equally strong candidates in the recorded `scrollRegion` rank before candidates in other regions. 3. Document order resolves the remaining ties. Suggestions are deduplicated by node. diff --git a/website/docs/docs/replay-e2e.md b/website/docs/docs/replay-e2e.md index d086291f1e..36557aed6d 100644 --- a/website/docs/docs/replay-e2e.md +++ b/website/docs/docs/replay-e2e.md @@ -324,7 +324,7 @@ A failing `replay`/`test` step returns a structured `REPLAY_DIVERGENCE` error. T - **`step`** — the 1-based plan index and its source file/line (through Maestro `runFlow` includes). - **`screen`** — a fresh post-failure snapshot digest with actionable refs, or `unavailable` with a reason/hint when capture failed or was sparse (never a stale tree). -- **`suggestions`** — up to 5 ranked, re-resolved candidates for the failing selector (id match ranks above role+label, which ranks above label-only), each with a `basis` you can inspect before acting. Equally strong candidates in the recorded scroll region rank first; document order breaks remaining ties. +- **`suggestions`** — up to 5 ranked, re-resolved candidates for the failing selector (id match ranks above role+label, which ranks above label-only), each with a `basis` you can inspect before acting. For a verified `target-v1` annotation, equally strong candidates in the recorded scroll region rank first; document order breaks remaining ties. - **`resume`** — whether and how to continue without re-running the script from the top. ```jsonc From a819228a6d6d78d6981184afa104eb59db7b3505 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 07:59:00 +0200 Subject: [PATCH 12/16] docs(sdk): describe the invalid lease response payload Review: https://github.com/callstack/agent-device/pull/3762#discussion_r4299087814 --- website/docs/docs/client-api.md | 1 + 1 file changed, 1 insertion(+) diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 9c59cde521..c5fa91262d 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -301,6 +301,7 @@ Domain client methods: - `client.settings.update()` If the daemon returns a missing or non-object lease, or a lease without a non-empty string for `leaseId`, `tenantId`, `runId`, or `backend`, `client.leases.allocate()` and `client.leases.heartbeat()` reject with `COMMAND_FAILED` and `details.reason: "INVALID_LEASE_RESPONSE"`. +The original daemon response is available in `error.details.response`. `client.devices.list()` returns `AgentDeviceDevice` entries. Their optional `model` and `osVersion` fields describe the hardware and OS when discovery reports them; see [Device discovery](/docs/commands#device-discovery) for the sources. From f7d56afaa8cd0e3303be615bfcf3fa24f84155f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 07:59:01 +0200 Subject: [PATCH 13/16] chore(gates): name the authority invariant and R75 owner Restore the actionable ADR and gate pointers in the SessionState shape-edge diagnostic. Review: https://github.com/callstack/agent-device/pull/3762#discussion_r4298986842 --- scripts/layering/session-authority-overlay.test.ts | 3 ++- scripts/layering/session-authority-overlay.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/layering/session-authority-overlay.test.ts b/scripts/layering/session-authority-overlay.test.ts index eada05ec96..6607d9ee7d 100644 --- a/scripts/layering/session-authority-overlay.test.ts +++ b/scripts/layering/session-authority-overlay.test.ts @@ -132,7 +132,8 @@ test('R75 fails a handler file that gains a shape or authority edge beyond the m assert.match(shape.message, /owning module's facade or a named semantic query/); assert.match(authority.message, /owning module's facade or a named store operation/); for (const { message } of [shape, authority]) { - assert.match(message, /change the invariant and the gate together/); + assert.match(message, /ADR 0022's authority invariant/); + assert.match(message, /R75 in scripts\/layering\/check\.ts/); assert.doesNotMatch(message, /accept the growth/); } }); diff --git a/scripts/layering/session-authority-overlay.ts b/scripts/layering/session-authority-overlay.ts index 1e2f4f8b1f..8d2724553e 100644 --- a/scripts/layering/session-authority-overlay.ts +++ b/scripts/layering/session-authority-overlay.ts @@ -95,7 +95,7 @@ export function checkSessionAuthorityOverlay( message: 'new handler-owned SessionState shape edge: the merge-base holds no such import. ' + "Route the read through the owning module's facade or a named semantic query. " + - 'If the boundary change is intended, change the invariant and the gate together.', + "If the boundary change is intended, change ADR 0022's authority invariant and R75 in scripts/layering/check.ts together.", }); } for (const file of scopedMeasured.authorityFiles) { @@ -107,7 +107,7 @@ export function checkSessionAuthorityOverlay( message: 'new handler-owned SessionStore authority edge: the merge-base holds no such import. ' + "Route the operation through the owning module's facade or a named store operation. " + - 'If the boundary change is intended, change the invariant and the gate together.', + "If the boundary change is intended, change ADR 0022's authority invariant and R75 in scripts/layering/check.ts together.", }); } return violations; From ab762104ed1202e85a96873fac8459fa1b4a7db9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 08:21:12 +0200 Subject: [PATCH 14/16] fix(replay): keep suggestion ranking synchronous Compute verified region membership at the divergence caller that owns target evidence. Keep the ranking entry dependency-free without deferred imports. --- ...sion-replay-divergence-suggestions.test.ts | 2 +- .../src/daemon-port/replay-backend-failure.ts | 11 +++--- .../daemon-port/session-replay-divergence.ts | 34 +++++++++++++------ .../session-replay-suggestion-ranking.ts | 26 +++----------- 4 files changed, 33 insertions(+), 40 deletions(-) diff --git a/packages/daemon/src/sessions/__tests__/replay-divergence/session-replay-divergence-suggestions.test.ts b/packages/daemon/src/sessions/__tests__/replay-divergence/session-replay-divergence-suggestions.test.ts index 75e431348d..e36a15f3e7 100644 --- a/packages/daemon/src/sessions/__tests__/replay-divergence/session-replay-divergence-suggestions.test.ts +++ b/packages/daemon/src/sessions/__tests__/replay-divergence/session-replay-divergence-suggestions.test.ts @@ -118,7 +118,7 @@ test.each(['region', 'absent', 'none', 'unverifiable'] as const)( ancestry: [], sibling: 0, viewportOrder: 0, - ...(evidence === 'region' + ...(evidence === 'region' || evidence === 'unverifiable' ? { scrollRegion: { role: 'scrollview', id: 'recorded' } } : {}), verification: diff --git a/packages/replay-port/src/daemon-port/replay-backend-failure.ts b/packages/replay-port/src/daemon-port/replay-backend-failure.ts index a5581099b7..d03bd2710d 100644 --- a/packages/replay-port/src/daemon-port/replay-backend-failure.ts +++ b/packages/replay-port/src/daemon-port/replay-backend-failure.ts @@ -54,13 +54,10 @@ export async function buildReplayBackendActionFailureResponse( }; const suggestions = session && observation.state === 'available' && !failure.isControl - ? ( - await rankAndDedupeReplaySuggestions( - failure - .suggestNodes({ createdAt: Date.now(), nodes: observation.nodes }) - .map(({ node, basis }) => ({ node, nodeIndex: node.index, basis })), - { nodes: observation.nodes, targetEvidence: undefined }, - ) + ? rankAndDedupeReplaySuggestions( + failure + .suggestNodes({ createdAt: Date.now(), nodes: observation.nodes }) + .map(({ node, basis }) => ({ node, nodeIndex: node.index, basis })), ).map(({ node, basis }) => buildReplayDivergenceSuggestionForNode({ node, diff --git a/packages/replay-port/src/daemon-port/session-replay-divergence.ts b/packages/replay-port/src/daemon-port/session-replay-divergence.ts index f43b874469..fa0de4edee 100644 --- a/packages/replay-port/src/daemon-port/session-replay-divergence.ts +++ b/packages/replay-port/src/daemon-port/session-replay-divergence.ts @@ -11,7 +11,12 @@ import { collectReplaySelectorCandidates } from '@agent-device/replay-port/sessi import { buildSelectorCandidates, resolveReplaySuggestionCandidate } from '@agent-device/selectors'; import { collectSettleChromeRefs } from '@agent-device/capture-kit/snapshot-chrome'; import { buildAndPersistReplayDivergenceResume } from '@agent-device/replay-port/session-replay-resume'; -import { formatDivergenceActionLabel, isTouchTargetCommand } from '@agent-device/ad-script'; +import { + buildIndexMap, + formatDivergenceActionLabel, + isTouchTargetCommand, +} from '@agent-device/ad-script'; +import { isInScrollRegion } from '@agent-device/selectors/target-evidence'; import { computeReplayRepairHint, type ReplayRepairHintCapture, @@ -112,7 +117,7 @@ export async function buildReplayFailureDivergence(params: { const screen = buildDivergenceScreen(observation, sanitize); const suggestions = observation.state === 'available' && session - ? await collectReplayDivergenceSuggestions({ + ? collectReplayDivergenceSuggestions({ action, session, nodes: observation.nodes, @@ -510,12 +515,12 @@ function buildReplayDivergenceScreenRefs( * Ranking: identity-component strength (id > role+label > label > other), * then recorded scroll region, then document order. */ -async function collectReplayDivergenceSuggestions(params: { +function collectReplayDivergenceSuggestions(params: { action: ReplayReportAction; session: ReplaySessionState; nodes: SnapshotNode[]; sanitize: DivergenceFieldSanitizer; -}): Promise { +}): ReplayDivergenceSuggestion[] { const { action, session, nodes, sanitize } = params; if (!isSuggestionEligibleCommand(action.command)) return []; const candidates = collectReplaySelectorCandidates(action); @@ -549,14 +554,14 @@ type RankedSuggestion = { nodeIndex: number; }; -async function rankSuggestionCandidates(params: { +function rankSuggestionCandidates(params: { candidates: string[]; nodes: SnapshotNode[]; session: ReplaySessionState; action: ReplayReportAction; matching: SuggestionMatchingConfig; sanitize: DivergenceFieldSanitizer; -}): Promise { +}): ReplayDivergenceSuggestion[] { const { candidates, nodes, session, action, matching, sanitize } = params; // Dedupe by node (its unique tree index), keeping the STRONGEST match basis // per the ADR: a node reachable through several recorded selector terms @@ -575,10 +580,19 @@ async function rankSuggestionCandidates(params: { if (!entry) continue; entries.push(entry); } - const ranked = await rankAndDedupeReplaySuggestions(entries, { - nodes, - targetEvidence: action.targetEvidence, - }); + let recordedRegionNodeIndices: Set | undefined; + const targetEvidence = action.targetEvidence; + if (targetEvidence?.verification === 'verified') { + const byIndex = buildIndexMap(nodes); + recordedRegionNodeIndices = new Set(); + for (const { nodeIndex } of entries) { + const node = byIndex.get(nodeIndex); + if (node && isInScrollRegion(node, byIndex, targetEvidence.scrollRegion)) { + recordedRegionNodeIndices.add(nodeIndex); + } + } + } + const ranked = rankAndDedupeReplaySuggestions(entries, recordedRegionNodeIndices); return ranked.map((entry) => entry.suggestion); } diff --git a/packages/replay-port/src/daemon-port/session-replay-suggestion-ranking.ts b/packages/replay-port/src/daemon-port/session-replay-suggestion-ranking.ts index a3364ca906..4f388cd231 100644 --- a/packages/replay-port/src/daemon-port/session-replay-suggestion-ranking.ts +++ b/packages/replay-port/src/daemon-port/session-replay-suggestion-ranking.ts @@ -1,5 +1,3 @@ -import type { TargetAnnotationV1 } from '@agent-device/contracts/replay'; -import type { SnapshotNode } from '@agent-device/kernel/snapshot'; import type { ReplayDivergenceSuggestionBasis } from '@agent-device/contracts/divergence'; const BASIS_RANK: Record = { @@ -9,18 +7,12 @@ const BASIS_RANK: Record = { other: 3, }; -export async function rankAndDedupeReplaySuggestions< +export function rankAndDedupeReplaySuggestions< T extends { readonly nodeIndex: number; readonly basis: ReplayDivergenceSuggestionBasis; }, ->( - entries: Iterable, - context: { - nodes: readonly SnapshotNode[]; - targetEvidence: TargetAnnotationV1 | undefined; - }, -): Promise { +>(entries: Iterable, recordedRegionNodeIndices: ReadonlySet = new Set()): T[] { const byNode = new Map(); for (const entry of entries) { const existing = byNode.get(entry.nodeIndex); @@ -28,21 +20,11 @@ export async function rankAndDedupeReplaySuggestions< byNode.set(entry.nodeIndex, entry); } } - const sameRegion = new Set(); - if (context.targetEvidence?.verification === 'verified') { - const { buildIndexMap } = await import('@agent-device/ad-script'); - const { isInScrollRegion } = await import('@agent-device/selectors/target-evidence'); - const byIndex = buildIndexMap(context.nodes); - for (const nodeIndex of byNode.keys()) { - const node = byIndex.get(nodeIndex); - if (node && isInScrollRegion(node, byIndex, context.targetEvidence.scrollRegion)) - sameRegion.add(nodeIndex); - } - } return [...byNode.values()].sort( (left, right) => basisRank(left.basis) - basisRank(right.basis) || - Number(sameRegion.has(right.nodeIndex)) - Number(sameRegion.has(left.nodeIndex)) || + Number(recordedRegionNodeIndices.has(right.nodeIndex)) - + Number(recordedRegionNodeIndices.has(left.nodeIndex)) || left.nodeIndex - right.nodeIndex, ); } From 703a7539ebbe0b2e95ff34a87588174a80256fb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 08:21:45 +0200 Subject: [PATCH 15/16] refactor(selectors): privatize scroll region primitives Keep isInScrollRegion as the shared consumer predicate and update the repair-hint comment to name it. --- packages/replay-port/src/daemon-port/repair-hint.ts | 2 +- packages/selectors/src/target-evidence.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/replay-port/src/daemon-port/repair-hint.ts b/packages/replay-port/src/daemon-port/repair-hint.ts index fac133d327..e428edf62c 100644 --- a/packages/replay-port/src/daemon-port/repair-hint.ts +++ b/packages/replay-port/src/daemon-port/repair-hint.ts @@ -15,7 +15,7 @@ * * The container-presence test below is a genuine structural containment check over `parentIndex` — the * same tree-walking machinery decision 3's own identity-set filter uses - * (`buildAncestryChain`/`computeScrollRegionKey`, `@agent-device/selectors/target-evidence`) + * (`buildAncestryChain`/`isInScrollRegion`, `@agent-device/selectors/target-evidence`) * — not a flat identity-string search. */ diff --git a/packages/selectors/src/target-evidence.ts b/packages/selectors/src/target-evidence.ts index 549d3281c1..f9072ac64b 100644 --- a/packages/selectors/src/target-evidence.ts +++ b/packages/selectors/src/target-evidence.ts @@ -182,7 +182,7 @@ export function computeSiblingOrdinal(nodes: readonly SnapshotNode[], node: Snap } /** Decision 3 record-time write step 4: nearest scrollable ancestor's local identity, or `undefined` for *none*. */ -export function computeScrollRegionKey( +function computeScrollRegionKey( node: SnapshotNode, byIndex: Map, ): TargetScrollRegion | undefined { @@ -199,7 +199,7 @@ export function computeScrollRegionKey( }; } -export function scrollRegionKeysEqual( +function scrollRegionKeysEqual( a: TargetScrollRegion | undefined, b: TargetScrollRegion | undefined, ): boolean { From d4696e26438c8542427562c109b2ab924ece4cc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 08:21:54 +0200 Subject: [PATCH 16/16] docs(replay): qualify recorded region ranking Apply the scroll-region tie-break only for a verified target-v1 annotation. Review: https://github.com/callstack/agent-device/pull/3762#discussion_r4301685497 --- .../replay-port/src/daemon-port/session-replay-divergence.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/replay-port/src/daemon-port/session-replay-divergence.ts b/packages/replay-port/src/daemon-port/session-replay-divergence.ts index fa0de4edee..ef1f317e33 100644 --- a/packages/replay-port/src/daemon-port/session-replay-divergence.ts +++ b/packages/replay-port/src/daemon-port/session-replay-divergence.ts @@ -513,7 +513,7 @@ function buildReplayDivergenceScreenRefs( /** * Decision 1's candidate machinery reused READ-ONLY over the shared capture. * Ranking: identity-component strength (id > role+label > label > other), - * then recorded scroll region, then document order. + * then recorded scroll region for a verified `target-v1` annotation, then document order. */ function collectReplayDivergenceSuggestions(params: { action: ReplayReportAction;