Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/adr/0012-interactive-replay.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,78 @@ test('buildReplayFailureDivergence dedupes suggestions using the strongest basis
expect(divergence.suggestions[0]?.ref).toBe('e1');
expect(divergence.suggestions[0]?.basis).toBe('id');
});

test.each(['region', 'absent', 'none', 'unverifiable'] as const)(
'suggestions rank identity, recorded region, then document order (evidence: %s)',
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' }));
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'),
{ ...button(6, 0, 'Outside'), parentIndex: undefined, depth: 0 },
],
truncated: false,
backend: 'xctest',
});
const action = {
ts: 0,
command: 'click',
positionals: ['label="Other"'],
flags: {},
result: {
selectorChain: ['label="Second"', 'label="First"', 'id="strong"', 'label="Outside"'],
},
...(evidence !== 'absent'
? {
targetEvidence: {
role: 'button',
label: 'Original',
ancestry: [],
sibling: 0,
viewportOrder: 0,
...(evidence === 'region' || evidence === 'unverifiable'
? { scrollRegion: { role: 'scrollview', id: 'recorded' } }
: {}),
verification:
evidence === 'unverifiable' ? ('unverifiable' as const) : ('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(
evidence === 'region'
? ['Strong', 'First', 'Second', 'Other', 'Outside']
: evidence === 'none'
? ['Strong', 'Outside', 'Other', 'First', 'Second']
: ['Strong', 'Other', 'First', 'Second', 'Outside'],
);
},
);
Original file line number Diff line number Diff line change
Expand Up @@ -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<MaestroRuntimeCommand, { kind: 'tapOn' }>;
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
20 changes: 8 additions & 12 deletions packages/daemon/src/sessions/handlers/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('../index.ts')>();
vi.mock('../internal/session-close.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../internal/session-close.ts')>();
return { ...actual, handleSessionCloseCommands: vi.fn(actual.handleSessionCloseCommands) };
});

vi.mock('../internal/inventory.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../internal/inventory.ts')>();
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<typeof import('../internal/session-open.ts')>();
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);
Expand Down Expand Up @@ -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);

Expand All @@ -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);

Expand Down
6 changes: 0 additions & 6 deletions packages/daemon/src/sessions/session-lifecycle/index.ts

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,16 @@ 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<typeof import('../index.ts')>();
vi.mock('../internal/session-observability.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../internal/session-observability.ts')>();
return {
...actual,
handleSessionObservabilityCommands: vi.fn(actual.handleSessionObservabilityCommands),
};
});

import { handleSessionCommands } from '../../handlers/session.ts';
import { handleSessionObservabilityCommands } from '../index.ts';
import { handleSessionObservabilityCommands } from '../internal/session-observability.ts';

const mockHandleSessionObservabilityCommands = vi.mocked(handleSessionObservabilityCommands);

Expand Down
2 changes: 0 additions & 2 deletions packages/daemon/src/sessions/session-observability/index.ts

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
9 changes: 3 additions & 6 deletions packages/replay-port/src/daemon-port/repair-hint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,15 @@
*
* 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.
*/

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[] }
Expand Down Expand Up @@ -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));
}

/**
Expand Down
Loading
Loading