Skip to content

Commit a54993b

Browse files
committed
feat: parse and preserve .ad target-v1 evidence (ADR 0012 migration step 3)
Recording now emits a `# agent-device:target-v1 {...}` comment immediately before every click/press/longpress/fill action that resolves through the tree (ref or selector), carrying the identity/ancestry/sibling/scrollRegion/ viewportOrder tuple from decision 3's record-time write algorithm plus a record-time self-check (verified/unverifiable). The parser accepts known fields in any order, NFC-normalizes, rejects malformed/oversized annotations with INVALID_ARGS, binds an annotation only to the immediately-next action line, and treats unknown future target-vN comments as ordinary comments. writeReplayScript's read-then-rewrite (heal) path preserves v1 annotations in canonical form. Nothing consumes the parsed evidence yet beyond preservation — replay-time enforcement is a later migration step. The identity tuple's own node/tree only ever lived on the internal visualization/session-history payload (never the public response); it is converted to the compact target-v1 evidence and stripped before anything is persisted or returned.
1 parent 66fe801 commit a54993b

16 files changed

Lines changed: 1958 additions & 10 deletions

src/commands/interaction/runtime/interactions.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,13 @@ test('runtime fill uses backend ref primitive without resolving snapshot geometr
127127
assert.deepEqual(calls, [{ ref: '@e1', text: 'hello', delayMs: 25 }]);
128128
assert.equal(result.kind, 'ref');
129129
assert.equal(result.point, undefined);
130-
assert.equal(result.node, undefined);
130+
// ADR 0012 decision 3: the native-ref preflight already looked this node up
131+
// (for the offscreen/occlusion guards) and now reuses that lookup to carry
132+
// `node`/`preActionNodes` for record-time target-binding evidence — at zero
133+
// extra capture cost. These never reach the public response (stripped by
134+
// `finalizeTouchInteraction`); this is the internal runtime result shape.
135+
assert.equal(result.node?.ref, 'e1');
136+
assert.ok(Array.isArray(result.preActionNodes));
131137
assert.deepEqual(result.target, { kind: 'ref', ref: '@e1' });
132138
assert.equal(result.text, 'hello');
133139
assert.deepEqual(result.backendResult, { ref: 'e1', text: 'hello' });

src/commands/interaction/runtime/resolution.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -492,7 +492,12 @@ export async function preflightNativeRefInteraction(
492492
options: CommandContext,
493493
target: Extract<InteractionTarget, { kind: 'ref' }>,
494494
action: InteractionAction,
495-
): Promise<{ targetHittable?: boolean; hint?: string }> {
495+
): Promise<{
496+
targetHittable?: boolean;
497+
hint?: string;
498+
node?: SnapshotNode;
499+
preActionNodes?: SnapshotNode[];
500+
}> {
496501
const session = await runtime.sessions.get(options.session ?? 'default');
497502
const nodes = session?.snapshot?.nodes;
498503
if (!nodes || normalizeRef(target.ref) === null) return {};
@@ -502,7 +507,18 @@ export async function preflightNativeRefInteraction(
502507
if (!resolved) return {};
503508
assertInteractionNotBlocked(resolved.node, `Ref ${target.ref}`, action);
504509
assertVisibleRefTarget(resolved.node, nodes, target.ref, action);
505-
return describeNonHittableTarget(resolved.node, action);
510+
return {
511+
...describeNonHittableTarget(resolved.node, action),
512+
// Reuses the lookup above (zero extra capture cost) so the native-ref
513+
// fast path — `click @ref`/`fill @ref` with default options — can still
514+
// record ADR 0012 decision-3 target-binding evidence from the stored
515+
// session tree, not only from the full resolution path. Stripped back
516+
// out of the response before it reaches any caller (see
517+
// `interaction-touch-response.ts`/`finalizeTouchInteraction`); never
518+
// exposed publicly.
519+
node: resolved.node,
520+
preActionNodes: nodes,
521+
};
506522
}
507523

508524
// isNodeVisibleOnScreen (not the effective-viewport form): items inside an

src/daemon/__tests__/session-store.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import path from 'node:path';
66
import { SessionStore } from '../session-store.ts';
77
import type { SessionState } from '../types.ts';
88
import { buildRequestFinishedEvent } from '../session-event-log.ts';
9+
import type { TargetAnnotationV1 } from '../../replay/target-identity.ts';
910

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

@@ -592,3 +593,56 @@ test('writeSessionLog preserves significant whitespace and empty string argument
592593
/--metro-host " host\\t" --launch-url "myapp:\/\/dev "/,
593594
]);
594595
});
596+
597+
// ---------------------------------------------------------------------------
598+
// ADR 0012 decision 3: recorded target-v1 evidence flows end to end through
599+
// `recordAction` → `SessionScriptWriter` into the `.ad` file, immediately
600+
// before the action it annotates.
601+
// ---------------------------------------------------------------------------
602+
603+
const SAVE_TARGET_EVIDENCE: TargetAnnotationV1 = {
604+
id: 'save',
605+
role: 'button',
606+
label: 'Save',
607+
ancestry: [{ role: 'toolbar', label: 'Editor' }],
608+
sibling: 0,
609+
viewportOrder: 0,
610+
verification: 'verified',
611+
};
612+
613+
test('writeSessionLog emits the target-v1 annotation immediately before its action line', () => {
614+
const fixture = makeFixture('agent-device-session-log-target-evidence-');
615+
recordOpen(fixture.store, fixture.session);
616+
fixture.store.recordAction(fixture.session, {
617+
command: 'click',
618+
positionals: ['@e12'],
619+
flags: { platform: 'ios' },
620+
result: {},
621+
targetEvidence: SAVE_TARGET_EVIDENCE,
622+
});
623+
recordClose(fixture.store, fixture.session);
624+
625+
const script = writeScript(fixture);
626+
const lines = script.trim().split('\n');
627+
const clickLineIndex = lines.findIndex((line) => line.startsWith('click '));
628+
assert.ok(clickLineIndex > 0);
629+
assert.equal(
630+
lines[clickLineIndex - 1],
631+
'# agent-device:target-v1 {"id":"save","role":"button","label":"Save","ancestry":[{"role":"toolbar","label":"Editor"}],"sibling":0,"viewportOrder":0,"verification":"verified"}',
632+
);
633+
});
634+
635+
test('writeSessionLog never fabricates a target-v1 annotation for actions recorded without evidence', () => {
636+
const fixture = makeFixture('agent-device-session-log-no-target-evidence-');
637+
recordOpen(fixture.store, fixture.session);
638+
fixture.store.recordAction(fixture.session, {
639+
command: 'click',
640+
positionals: ['@e12'],
641+
flags: { platform: 'ios' },
642+
result: {},
643+
});
644+
recordClose(fixture.store, fixture.session);
645+
646+
const script = writeScript(fixture);
647+
assert.equal(/agent-device:target-v1/.test(script), false);
648+
});

0 commit comments

Comments
 (0)