Skip to content
Merged
5 changes: 4 additions & 1 deletion src/commands/interaction/runtime/interactions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,10 @@ test('runtime fill uses backend ref primitive without resolving snapshot geometr
assert.deepEqual(calls, [{ ref: '@e1', text: 'hello', delayMs: 25 }]);
assert.equal(result.kind, 'ref');
assert.equal(result.point, undefined);
assert.equal(result.node, undefined);
// ADR 0012 decision 3: the preflight's guard lookup supplies the
// record-time evidence node on the runtime result.
assert.equal(result.node?.ref, 'e1');
assert.ok(Array.isArray(result.preActionNodes));
assert.deepEqual(result.target, { kind: 'ref', ref: '@e1' });
assert.equal(result.text, 'hello');
assert.deepEqual(result.backendResult, { ref: 'e1', text: 'hello' });
Expand Down
15 changes: 13 additions & 2 deletions src/commands/interaction/runtime/resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -492,7 +492,12 @@ export async function preflightNativeRefInteraction(
options: CommandContext,
target: Extract<InteractionTarget, { kind: 'ref' }>,
action: InteractionAction,
): Promise<{ targetHittable?: boolean; hint?: string }> {
): Promise<{
targetHittable?: boolean;
hint?: string;
node?: SnapshotNode;
preActionNodes?: SnapshotNode[];
}> {
const session = await runtime.sessions.get(options.session ?? 'default');
const nodes = session?.snapshot?.nodes;
if (!nodes || normalizeRef(target.ref) === null) return {};
Expand All @@ -502,7 +507,13 @@ export async function preflightNativeRefInteraction(
if (!resolved) return {};
assertInteractionNotBlocked(resolved.node, `Ref ${target.ref}`, action);
assertVisibleRefTarget(resolved.node, nodes, target.ref, action);
return describeNonHittableTarget(resolved.node, action);
return {
...describeNonHittableTarget(resolved.node, action),
// ADR 0012 decision 3: the guard lookup above doubles as the record-time
// evidence source for the fast path, at zero extra capture cost.
node: resolved.node,
preActionNodes: nodes,
};
}

// isNodeVisibleOnScreen (not the effective-viewport form): items inside an
Expand Down
11 changes: 9 additions & 2 deletions src/commands/interaction/runtime/selector-read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,16 @@ export type GetCommandResult =
text: string;
node: SnapshotNode;
selectorChain?: string[];
/** ADR 0012 decision 3: the tree `node` was resolved from, for record-time evidence. */
preActionNodes: SnapshotNode[];
}
| {
kind: 'attrs';
target: ResolvedTarget;
node: SnapshotNode;
selectorChain?: string[];
/** ADR 0012 decision 3: the tree `node` was resolved from, for record-time evidence. */
preActionNodes: SnapshotNode[];
};

export type GetTextCommandOptions = CommandContext &
Expand Down Expand Up @@ -199,11 +203,12 @@ export const getCommand: RuntimeCommand<GetCommandOptions, GetCommandResult> = a
action: 'get',
});
const target = { kind: 'ref' as const, ref: `@${resolved.ref}` };
const preActionNodes = capture.snapshot.nodes;
if (options.property === 'attrs') {
return { kind: 'attrs', target, node: resolved.node, selectorChain };
return { kind: 'attrs', target, node: resolved.node, selectorChain, preActionNodes };
}
const text = await readText(runtime, capture, resolved.node);
return { kind: 'text', target, text, node: resolved.node, selectorChain };
return { kind: 'text', target, text, node: resolved.node, selectorChain, preActionNodes };
}

const resolved = await resolveSelectorNode(runtime, options, options.session ?? 'default', {
Expand All @@ -221,6 +226,7 @@ export const getCommand: RuntimeCommand<GetCommandOptions, GetCommandResult> = a
target: { kind: 'selector', selector: resolved.selector },
node: resolved.node,
selectorChain,
preActionNodes: resolved.capture.snapshot.nodes,
};
}

Expand All @@ -231,6 +237,7 @@ export const getCommand: RuntimeCommand<GetCommandOptions, GetCommandResult> = a
text,
node: resolved.node,
selectorChain,
preActionNodes: resolved.capture.snapshot.nodes,
};
};

Expand Down
54 changes: 54 additions & 0 deletions src/daemon/__tests__/session-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import path from 'node:path';
import { SessionStore } from '../session-store.ts';
import type { SessionState } from '../types.ts';
import { buildRequestFinishedEvent } from '../session-event-log.ts';
import type { TargetAnnotationV1 } from '../../replay/target-identity.ts';

type RecordActionEntry = Parameters<SessionStore['recordAction']>[1];

Expand Down Expand Up @@ -592,3 +593,56 @@ test('writeSessionLog preserves significant whitespace and empty string argument
/--metro-host " host\\t" --launch-url "myapp:\/\/dev "/,
]);
});

// ---------------------------------------------------------------------------
// ADR 0012 decision 3: recorded target-v1 evidence flows end to end through
// `recordAction` → `SessionScriptWriter` into the `.ad` file, immediately
// before the action it annotates.
// ---------------------------------------------------------------------------

const SAVE_TARGET_EVIDENCE: TargetAnnotationV1 = {
id: 'save',
role: 'button',
label: 'Save',
ancestry: [{ role: 'toolbar', label: 'Editor' }],
sibling: 0,
viewportOrder: 0,
verification: 'verified',
};

test('writeSessionLog emits the target-v1 annotation immediately before its action line', () => {
const fixture = makeFixture('agent-device-session-log-target-evidence-');
recordOpen(fixture.store, fixture.session);
fixture.store.recordAction(fixture.session, {
command: 'click',
positionals: ['@e12'],
flags: { platform: 'ios' },
result: {},
targetEvidence: SAVE_TARGET_EVIDENCE,
});
recordClose(fixture.store, fixture.session);

const script = writeScript(fixture);
const lines = script.trim().split('\n');
const clickLineIndex = lines.findIndex((line) => line.startsWith('click '));
assert.ok(clickLineIndex > 0);
assert.equal(
lines[clickLineIndex - 1],
'# agent-device:target-v1 {"id":"save","role":"button","label":"Save","ancestry":[{"role":"toolbar","label":"Editor"}],"sibling":0,"viewportOrder":0,"verification":"verified"}',
);
});

test('writeSessionLog never fabricates a target-v1 annotation for actions recorded without evidence', () => {
const fixture = makeFixture('agent-device-session-log-no-target-evidence-');
recordOpen(fixture.store, fixture.session);
fixture.store.recordAction(fixture.session, {
command: 'click',
positionals: ['@e12'],
flags: { platform: 'ios' },
result: {},
});
recordClose(fixture.store, fixture.session);

const script = writeScript(fixture);
assert.equal(/agent-device:target-v1/.test(script), false);
});
Loading
Loading