Skip to content

Commit 17b30bf

Browse files
committed
fix(replay): preserve authored and explicitly recorded snapshots
ADR 0012 requires authored observations and explicit --record corrections to survive healed-script publication. Carry their typed publication intent from recording into the writer instead of stripping every snapshot. Retain ordinary diagnostic-snapshot optimization. Isolated regression at base 4c1c786: authored and explicit-record persisted snapshots and the correction-count/resume publication scenario all fail (3 regressions); all pass with the fix. Combined before: 9 failed, 43 passed; after: 52 passed. Recorder/resume focused coverage also passes (82 tests). Device dispatch is unchanged.
1 parent dacec4a commit 17b30bf

5 files changed

Lines changed: 121 additions & 76 deletions

File tree

‎packages/contracts/src/session-action.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export type SessionAction = {
2828
noRecord?: boolean;
2929
};
3030
result?: Record<string, unknown>;
31+
/** Retain authored plan observations and explicitly recorded corrections during publication. */
32+
publicationIntent?: 'authored' | 'explicit-record';
3133
/**
3234
* ADR 0012 decision 3: parsed or record-time-computed `target-v1`
3335
* evidence, written as a comment immediately before this action's line.

‎packages/daemon/src/core/__tests__/session-script-writer.test.ts‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { test, expect, vi } from 'vitest';
33
import fs from 'node:fs';
44
import path from 'node:path';
55
import { HEAL_COMPLETE_SENTINEL, SessionScriptWriter } from '../session-script-writer.ts';
6-
import { recordActionEntry } from '../session-action-recorder.ts';
6+
import { isInteractiveObservation, recordActionEntry } from '../session-action-recorder.ts';
77
import {
88
makeAuthoringSession,
99
makeRepairArmedSession,
@@ -119,6 +119,35 @@ test('write() carries a recorded wait --raw through to the script and back', ()
119119
expect(parsed.actions[0]?.flags.snapshotRaw).toBe(true);
120120
});
121121

122+
test.each(['authored', 'explicit-record'] as const)(
123+
'healed output preserves a snapshot with %s publication intent',
124+
(intent) => {
125+
const root = mkdtempForTestSync('agent-device-script-writer-retained-snapshot-');
126+
const writer = new SessionScriptWriter(path.join(root, 'sessions'));
127+
const session = makeRepairCompleteSession('default');
128+
const req = {
129+
token: 't',
130+
session: 'default',
131+
command: 'snapshot',
132+
positionals: [],
133+
flags:
134+
intent === 'explicit-record'
135+
? { record: true, snapshotInteractiveOnly: true }
136+
: { snapshotInteractiveOnly: true },
137+
...(intent === 'authored' ? { internal: { replayPlanStep: true } } : {}),
138+
};
139+
recordActionEntry(session, {
140+
command: req.command,
141+
positionals: req.positionals,
142+
flags: req.flags,
143+
interactiveObservation: isInteractiveObservation(req),
144+
});
145+
const { parsed } = writeAndParse(writer, session);
146+
expect(parsed.actions.map((entry) => entry.command)).toEqual(['snapshot']);
147+
expect(parsed.actions[0]?.flags.snapshotInteractiveOnly).toBe(true);
148+
},
149+
);
150+
122151
test('a boundary-sliced script still strips diagnostic snapshot actions', () => {
123152
const root = mkdtempForTestSync('agent-device-script-writer-snapshot-strip-');
124153
const writer = new SessionScriptWriter(path.join(root, 'sessions'));

‎packages/daemon/src/core/session-action-recorder.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@ export function recordActionEntry(
8686
runtime: recordedEntry.runtime,
8787
flags: sanitizeFlags(recordedEntry.flags),
8888
result: recordedEntry.result,
89+
...(recordedEntry.flags.record === true
90+
? { publicationIntent: 'explicit-record' as const }
91+
: recordedEntry.interactiveObservation === false
92+
? { publicationIntent: 'authored' as const }
93+
: {}),
8994
...(recordedEntry.targetEvidence ? { targetEvidence: recordedEntry.targetEvidence } : {}),
9095
...(recordedEntry.targetEvidences ? { targetEvidences: recordedEntry.targetEvidences } : {}),
9196
};

‎packages/daemon/src/core/session-script-writer.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,7 @@ function buildOptimizedActions(
312312
const relevantActions = session.actions.slice(boundary ?? 0);
313313
const optimized: SessionAction[] = [];
314314
for (const action of relevantActions) {
315-
if (action.command === 'snapshot') continue;
315+
if (action.command === 'snapshot' && action.publicationIntent === undefined) continue;
316316
const optimizedAction = optimizeSelectorChainAction(action);
317317
if (optimizedAction) {
318318
optimized.push(optimizedAction);

‎packages/daemon/src/sessions/__tests__/replay-repair/session-replay-repair-record-exclusion.test.ts‎

Lines changed: 83 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -281,85 +281,88 @@ test('diagnostic get/is reads mid-repair are excluded from the healed script by
281281
expect(freshRun.ok).toBe(true);
282282
});
283283

284-
test("a --record'ed diagnostic read lands in the healed script (the diverged-step-was-a-get case)", async () => {
285-
const ctx = setup('agent-device-repair-record-exclusion-record-');
286-
const filePath = writeReplayFile(ctx.root, [
287-
'open "Demo" --relaunch',
288-
SAVE_ANNOTATION,
289-
'click id="save"',
290-
'click id="confirm"',
291-
'close',
292-
]);
293-
const { invoke, divergence } = await armAndDiverge(ctx, filePath);
294-
const session = ctx.sessionStore.get(ctx.sessionName)!;
284+
test.each(['get', 'snapshot'])(
285+
"a --record'ed %s correction satisfies the resume guard and persists in the healed script",
286+
async (command) => {
287+
const ctx = setup('agent-device-repair-record-exclusion-record-');
288+
const filePath = writeReplayFile(ctx.root, [
289+
'open "Demo" --relaunch',
290+
SAVE_ANNOTATION,
291+
'click id="save"',
292+
'click id="confirm"',
293+
'close',
294+
]);
295+
const { invoke, divergence } = await armAndDiverge(ctx, filePath);
296+
const session = ctx.sessionStore.get(ctx.sessionName)!;
295297

296-
// --- The agent's correction for the diverged step IS itself a read (the
297-
// wave-3 E3 shape): `get attrs` on the renamed control, explicitly forced
298-
// into the heal with `--record` since it would otherwise be excluded. ---
299-
ctx.sessionStore.recordAction(storeSessionForTest(ctx.sessionStore, session), {
300-
command: 'get',
301-
positionals: ['attrs', '@e7'],
302-
flags: { record: true },
303-
result: { selectorChain: ['id="save-v2"'] },
304-
interactiveObservation: true,
305-
});
306-
expect(session.actions.map((a) => a.command)).toEqual(['open', 'get']);
298+
// --- The agent's correction for the diverged step IS itself a read (the
299+
// wave-3 E3 shape): `get attrs` on the renamed control, explicitly forced
300+
// into the heal with `--record` since it would otherwise be excluded. ---
301+
ctx.sessionStore.recordAction(storeSessionForTest(ctx.sessionStore, session), {
302+
command,
303+
positionals: command === 'get' ? ['attrs', '@e7'] : [],
304+
flags: { record: true },
305+
result: { selectorChain: ['id="save-v2"'] },
306+
interactiveObservation: true,
307+
});
308+
expect(session.actions.map((a) => a.command)).toEqual(['open', command]);
307309

308-
const leg2 = await runReplayForTest({
309-
req: baseReq({
310-
positionals: [filePath],
311-
flags: { saveScript: true, replayFrom: 3, replayPlanDigest: divergence.resume.planDigest },
312-
}),
313-
sessionName: ctx.sessionName,
314-
logPath: ctx.logPath,
315-
sessionStore: ctx.sessionStore,
316-
invoke,
317-
});
318-
expect(leg2.ok).toBe(true);
319-
expect(session.actions.map((a) => a.command)).toEqual(['open', 'get', 'click']);
310+
const leg2 = await runReplayForTest({
311+
req: baseReq({
312+
positionals: [filePath],
313+
flags: { saveScript: true, replayFrom: 3, replayPlanDigest: divergence.resume.planDigest },
314+
}),
315+
sessionName: ctx.sessionName,
316+
logPath: ctx.logPath,
317+
sessionStore: ctx.sessionStore,
318+
invoke,
319+
});
320+
expect(leg2.ok).toBe(true);
321+
expect(session.actions.map((a) => a.command)).toEqual(['open', command, 'click']);
320322

321-
const closeResponse = await handleCloseCommand({
322-
platformServices: daemonPlatformServicesFixture(),
323-
req: {
324-
token: 't',
325-
session: ctx.sessionName,
326-
command: 'close',
327-
positionals: [],
328-
flags: { saveScript: true },
329-
},
330-
sessionName: ctx.sessionName,
331-
logPath: ctx.logPath,
332-
sessionStore: ctx.sessionStore,
333-
leaseRegistry: ctx.leaseRegistry,
334-
});
335-
expect(closeResponse.ok).toBe(true);
323+
const closeResponse = await handleCloseCommand({
324+
platformServices: daemonPlatformServicesFixture(),
325+
req: {
326+
token: 't',
327+
session: ctx.sessionName,
328+
command: 'close',
329+
positionals: [],
330+
flags: { saveScript: true },
331+
},
332+
sessionName: ctx.sessionName,
333+
logPath: ctx.logPath,
334+
sessionStore: ctx.sessionStore,
335+
leaseRegistry: ctx.leaseRegistry,
336+
});
337+
expect(closeResponse.ok).toBe(true);
336338

337-
const healedPath = path.join(ctx.root, 'flow.healed.ad');
338-
const healedScript = fs.readFileSync(healedPath, 'utf8');
339-
const parsed = parseReplayScriptDetailed(healedScript);
340-
// The --record'ed corrective read DOES land in the heal, resolved to a
341-
// selector (never a bare @ref).
342-
expect(parsed.actions.map((a) => a.command)).toEqual(['open', 'get', 'click', 'close']);
343-
const getAction = parsed.actions.find((a) => a.command === 'get');
344-
expect(getAction?.positionals).toEqual(['attrs', 'id="save-v2"']);
345-
const bareRefs = parsed.actions.flatMap((a) => a.positionals.filter((p) => p.startsWith('@')));
346-
expect(bareRefs).toEqual([]);
339+
const healedPath = path.join(ctx.root, 'flow.healed.ad');
340+
const healedScript = fs.readFileSync(healedPath, 'utf8');
341+
const parsed = parseReplayScriptDetailed(healedScript);
342+
// The --record'ed corrective read DOES land in the heal, resolved to a
343+
// selector (never a bare @ref).
344+
expect(parsed.actions.map((a) => a.command)).toEqual(['open', command, 'click', 'close']);
345+
const correction = parsed.actions.find((a) => a.command === command);
346+
expect(correction?.positionals).toEqual(command === 'get' ? ['attrs', 'id="save-v2"'] : []);
347+
const bareRefs = parsed.actions.flatMap((a) => a.positionals.filter((p) => p.startsWith('@')));
348+
expect(bareRefs).toEqual([]);
347349

348-
// --- Replays cleanly in a fresh session too. ---
349-
const fresh = setup('agent-device-repair-record-exclusion-record-fresh-');
350-
const freshInvoke = makeRecordingReplayInvoke({
351-
sessionStore: fresh.sessionStore,
352-
sessionName: fresh.sessionName,
353-
});
354-
const freshRun = await runReplayForTest({
355-
req: baseReq({ positionals: [healedPath] }),
356-
sessionName: fresh.sessionName,
357-
logPath: fresh.logPath,
358-
sessionStore: fresh.sessionStore,
359-
invoke: freshInvoke,
360-
});
361-
expect(freshRun.ok).toBe(true);
362-
});
350+
// --- Replays cleanly in a fresh session too. ---
351+
const fresh = setup('agent-device-repair-record-exclusion-record-fresh-');
352+
const freshInvoke = makeRecordingReplayInvoke({
353+
sessionStore: fresh.sessionStore,
354+
sessionName: fresh.sessionName,
355+
});
356+
const freshRun = await runReplayForTest({
357+
req: baseReq({ positionals: [healedPath] }),
358+
sessionName: fresh.sessionName,
359+
logPath: fresh.logPath,
360+
sessionStore: fresh.sessionStore,
361+
invoke: freshInvoke,
362+
});
363+
expect(freshRun.ok).toBe(true);
364+
},
365+
);
363366

364367
test('empty-segment guard: a --from resume refuses with an actionable --record hint when only excluded diagnostic reads happened since the divergence', async () => {
365368
const ctx = setup('agent-device-repair-record-exclusion-empty-segment-');
@@ -390,6 +393,12 @@ test('empty-segment guard: a --from resume refuses with an actionable --record h
390393
result: { found: true },
391394
interactiveObservation: true,
392395
});
396+
ctx.sessionStore.recordAction(storeSessionForTest(ctx.sessionStore, session), {
397+
command: 'snapshot',
398+
positionals: [],
399+
flags: {},
400+
interactiveObservation: true,
401+
});
393402
expect(session.actions.map((a) => a.command)).toEqual(['open']);
394403

395404
const blindResume = await runReplayForTest({

0 commit comments

Comments
 (0)