From 63d9a17e67d9e400df27ec4051bed0f4f2936d98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 22:04:00 +0200 Subject: [PATCH 01/15] fix(daemon): retain execution locks through binding cleanup ADR 0019 requires request bindings to dispose before device/session lock release. Await the shared cleanup outcome under initial and retained execution locks, then let response finalization preserve primary failures and report cleanup failures. At base 4c1c786968, six blocked-cleanup regressions fail: queued same-session or same-device requests enter on success, throw, and cancellation. With the fix all six pass; the scope and open-wait suites pass (42 tests). No request-binding disposal bound exists; no timeout was added. Device dispatch is unchanged. --- .../request-runtime-binding-router.test.ts | 67 ++++++++++++++++++- .../src/server/request-execution-scope.ts | 33 ++++++--- 2 files changed, 90 insertions(+), 10 deletions(-) diff --git a/packages/daemon/src/core/__tests__/request-runtime-binding-router.test.ts b/packages/daemon/src/core/__tests__/request-runtime-binding-router.test.ts index 15cd4e57fc..eb2b59ca16 100644 --- a/packages/daemon/src/core/__tests__/request-runtime-binding-router.test.ts +++ b/packages/daemon/src/core/__tests__/request-runtime-binding-router.test.ts @@ -1,5 +1,11 @@ import fs from 'node:fs'; import { expect, test, vi } from 'vitest'; +import { + clearRequestCanceled, + markRequestCanceled, + throwIfRequestCanceled, +} from '@agent-device/host-kit/request'; +import { AppError } from '@agent-device/kernel/errors'; import { applicationLifecycleOperationFacts } from '@agent-device/contracts/application-lifecycle-runtime'; import { type DeviceBinding, @@ -51,6 +57,65 @@ test('primary request failure survives a rejecting binding disposal', async () = expect(phases).toContain('request_binding_cleanup_failed'); }); +for (const outcome of ['success', 'throw', 'cancellation'] as const) { + for (const key of ['session', 'device'] as const) { + test(`binding cleanup holds the ${key} execution lock after ${outcome}`, async () => { + const runtime = makeGateway(); + const { handler, sessionStore } = makeHandler(runtime.gateway); + const current = sessionStore.get('session')!; + sessionStore.publish('other-session', { ...current, name: 'other-session', actions: [] }); + const requestId = `cleanup-${key}-${outcome}`; + let markCleanupStarted: () => void = () => {}; + let finishCleanup: () => void = () => {}; + const cleanupStarted = new Promise((resolve) => { + markCleanupStarted = resolve; + }); + const cleanupFinished = new Promise((resolve) => { + finishCleanup = resolve; + }); + runtime.bindingDispose.mockImplementationOnce(async () => { + markCleanupStarted(); + await cleanupFinished; + }); + let doctorCalls = 0; + vi.spyOn(runtime.operations, 'appLogDoctor').mockImplementation(async () => { + if (doctorCalls++ > 0) return { backend: 'android', checks: {}, notes: [] }; + if (outcome === 'throw') throw new AppError('COMMAND_FAILED', 'doctor failed'); + if (outcome === 'cancellation') { + markRequestCanceled(requestId); + throwIfRequestCanceled(requestId); + } + return { backend: 'android', checks: {}, notes: [] }; + }); + const first = handler({ ...request(['doctor']), meta: { requestId } }); + await cleanupStarted; + const second = handler({ + ...request(['doctor']), + session: key === 'session' ? 'session' : 'other-session', + meta: { requestId: `${requestId}-queued` }, + }); + try { + await new Promise((resolve) => setImmediate(resolve)); + expect(runtime.bind).toHaveBeenCalledTimes(1); + } finally { + finishCleanup(); + clearRequestCanceled(requestId); + } + const [firstResponse, secondResponse] = await Promise.all([first, second]); + expect(firstResponse.ok).toBe(outcome === 'success'); + if (outcome === 'cancellation') { + expect(firstResponse).toMatchObject({ + ok: false, + error: { code: 'COMMAND_FAILED', details: { reason: 'request_canceled' } }, + }); + } + expect(secondResponse.ok).toBe(true); + expect(runtime.bind).toHaveBeenCalledTimes(2); + expect(runtime.bindingDispose).toHaveBeenCalledTimes(2); + }); + } +} + function makeHandler(gateway: DeviceRuntimeGateway) { const sessionStore = makeSessionStore('request-runtime-binding-router-'); sessionStore.publish('session', { @@ -181,7 +246,7 @@ function makeGateway(disposeError?: Error) { bind, shutdown: async () => {}, }; - return { gateway, bind, bindingDispose, forceCleanup, handle }; + return { gateway, bind, bindingDispose, forceCleanup, handle, operations }; } const unavailableRecording = Object.freeze({ diff --git a/packages/daemon/src/server/request-execution-scope.ts b/packages/daemon/src/server/request-execution-scope.ts index 21eef92c7a..26f2cb74ee 100644 --- a/packages/daemon/src/server/request-execution-scope.ts +++ b/packages/daemon/src/server/request-execution-scope.ts @@ -266,6 +266,27 @@ export async function createRequestExecutionScope(params: { claimRecoveryGateway: params.claimRecoveryGateway, }); + let bindingCleanup: Promise | undefined; + const disposeBindings = (): Promise => { + bindingCleanup ??= (async () => { + try { + await runtimeBindings?.[Symbol.asyncDispose](); + } finally { + await claimAdmission?.[Symbol.asyncDispose](); + } + })(); + return bindingCleanup; + }; + const runAdmittedAndDispose = async (task: () => Promise): Promise => { + try { + return await scope.runAdmitted(task); + } finally { + // The router consumes the same cleanup outcome when it finalizes the response, + // preserving a primary command failure over a secondary disposal failure. + await Promise.allSettled([disposeBindings()]); + } + }; + const scope: RequestExecutionScope = { req: scopedReq, command, @@ -330,22 +351,16 @@ export async function createRequestExecutionScope(params: { runLocked: async (task) => { throwIfRequestCanceled(scopedReq.meta?.requestId); if (!openWait) { - return await requestExecutionLocks.run(async () => await scope.runAdmitted(task)); + return await requestExecutionLocks.run(async () => await runAdmittedAndDispose(task)); } return await openWait.runWhenDeviceIsUnheld({ acquireLocks: requestExecutionLocks.run, - task: async () => await scope.runAdmitted(task), + task: async () => await runAdmittedAndDispose(task), }); }, // Claims outlive the bindings they guard: release only once no device // operation from this request can still run. - [Symbol.asyncDispose]: async () => { - try { - await runtimeBindings?.[Symbol.asyncDispose](); - } finally { - await claimAdmission?.[Symbol.asyncDispose](); - } - }, + [Symbol.asyncDispose]: disposeBindings, }; requestScopeFinalizers.set(scope, (response) => { if (shouldRecordRequestEvents) { From 24905daf2d932bc1fa30d74aefc94498d717b981 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 22:04:12 +0200 Subject: [PATCH 02/15] 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 4c1c786968: 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. --- packages/contracts/src/session-action.ts | 2 + .../__tests__/session-script-writer.test.ts | 31 +++- .../src/core/session-action-recorder.ts | 5 + .../daemon/src/core/session-script-writer.ts | 2 +- ...ion-replay-repair-record-exclusion.test.ts | 157 +++++++++--------- 5 files changed, 121 insertions(+), 76 deletions(-) diff --git a/packages/contracts/src/session-action.ts b/packages/contracts/src/session-action.ts index 666bce9492..79185d17de 100644 --- a/packages/contracts/src/session-action.ts +++ b/packages/contracts/src/session-action.ts @@ -28,6 +28,8 @@ export type SessionAction = { noRecord?: boolean; }; result?: Record; + /** Retain authored plan observations and explicitly recorded corrections during publication. */ + publicationIntent?: 'authored' | 'explicit-record'; /** * ADR 0012 decision 3: parsed or record-time-computed `target-v1` * evidence, written as a comment immediately before this action's line. diff --git a/packages/daemon/src/core/__tests__/session-script-writer.test.ts b/packages/daemon/src/core/__tests__/session-script-writer.test.ts index 6e88b8cd3a..ce8702b73a 100644 --- a/packages/daemon/src/core/__tests__/session-script-writer.test.ts +++ b/packages/daemon/src/core/__tests__/session-script-writer.test.ts @@ -3,7 +3,7 @@ import { test, expect, vi } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import { HEAL_COMPLETE_SENTINEL, SessionScriptWriter } from '../session-script-writer.ts'; -import { recordActionEntry } from '../session-action-recorder.ts'; +import { isInteractiveObservation, recordActionEntry } from '../session-action-recorder.ts'; import { makeAuthoringSession, makeRepairArmedSession, @@ -119,6 +119,35 @@ test('write() carries a recorded wait --raw through to the script and back', () expect(parsed.actions[0]?.flags.snapshotRaw).toBe(true); }); +test.each(['authored', 'explicit-record'] as const)( + 'healed output preserves a snapshot with %s publication intent', + (intent) => { + const root = mkdtempForTestSync('agent-device-script-writer-retained-snapshot-'); + const writer = new SessionScriptWriter(path.join(root, 'sessions')); + const session = makeRepairCompleteSession('default'); + const req = { + token: 't', + session: 'default', + command: 'snapshot', + positionals: [], + flags: + intent === 'explicit-record' + ? { record: true, snapshotInteractiveOnly: true } + : { snapshotInteractiveOnly: true }, + ...(intent === 'authored' ? { internal: { replayPlanStep: true } } : {}), + }; + recordActionEntry(session, { + command: req.command, + positionals: req.positionals, + flags: req.flags, + interactiveObservation: isInteractiveObservation(req), + }); + const { parsed } = writeAndParse(writer, session); + expect(parsed.actions.map((entry) => entry.command)).toEqual(['snapshot']); + expect(parsed.actions[0]?.flags.snapshotInteractiveOnly).toBe(true); + }, +); + test('a boundary-sliced script still strips diagnostic snapshot actions', () => { const root = mkdtempForTestSync('agent-device-script-writer-snapshot-strip-'); const writer = new SessionScriptWriter(path.join(root, 'sessions')); diff --git a/packages/daemon/src/core/session-action-recorder.ts b/packages/daemon/src/core/session-action-recorder.ts index 3617e1c251..3df853c258 100644 --- a/packages/daemon/src/core/session-action-recorder.ts +++ b/packages/daemon/src/core/session-action-recorder.ts @@ -86,6 +86,11 @@ export function recordActionEntry( runtime: recordedEntry.runtime, flags: sanitizeFlags(recordedEntry.flags), result: recordedEntry.result, + ...(recordedEntry.flags.record === true + ? { publicationIntent: 'explicit-record' as const } + : recordedEntry.interactiveObservation === false + ? { publicationIntent: 'authored' as const } + : {}), ...(recordedEntry.targetEvidence ? { targetEvidence: recordedEntry.targetEvidence } : {}), ...(recordedEntry.targetEvidences ? { targetEvidences: recordedEntry.targetEvidences } : {}), }; diff --git a/packages/daemon/src/core/session-script-writer.ts b/packages/daemon/src/core/session-script-writer.ts index 3c410ca358..ac1f2e0f2c 100644 --- a/packages/daemon/src/core/session-script-writer.ts +++ b/packages/daemon/src/core/session-script-writer.ts @@ -312,7 +312,7 @@ function buildOptimizedActions( const relevantActions = session.actions.slice(boundary ?? 0); const optimized: SessionAction[] = []; for (const action of relevantActions) { - if (action.command === 'snapshot') continue; + if (action.command === 'snapshot' && action.publicationIntent === undefined) continue; const optimizedAction = optimizeSelectorChainAction(action); if (optimizedAction) { optimized.push(optimizedAction); 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..b1f866bef2 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 @@ -281,85 +281,88 @@ test('diagnostic get/is reads mid-repair are excluded from the healed script by expect(freshRun.ok).toBe(true); }); -test("a --record'ed diagnostic read lands in the healed script (the diverged-step-was-a-get case)", async () => { - const ctx = setup('agent-device-repair-record-exclusion-record-'); - const filePath = writeReplayFile(ctx.root, [ - 'open "Demo" --relaunch', - SAVE_ANNOTATION, - 'click id="save"', - 'click id="confirm"', - 'close', - ]); - const { invoke, divergence } = await armAndDiverge(ctx, filePath); - const session = ctx.sessionStore.get(ctx.sessionName)!; +test.each(['get', 'snapshot'])( + "a --record'ed %s correction satisfies the resume guard and persists in the healed script", + async (command) => { + const ctx = setup('agent-device-repair-record-exclusion-record-'); + const filePath = writeReplayFile(ctx.root, [ + 'open "Demo" --relaunch', + SAVE_ANNOTATION, + 'click id="save"', + 'click id="confirm"', + 'close', + ]); + const { invoke, divergence } = await armAndDiverge(ctx, filePath); + const session = ctx.sessionStore.get(ctx.sessionName)!; - // --- The agent's correction for the diverged step IS itself a read (the - // wave-3 E3 shape): `get attrs` on the renamed control, explicitly forced - // into the heal with `--record` since it would otherwise be excluded. --- - ctx.sessionStore.recordAction(storeSessionForTest(ctx.sessionStore, session), { - command: 'get', - positionals: ['attrs', '@e7'], - flags: { record: true }, - result: { selectorChain: ['id="save-v2"'] }, - interactiveObservation: true, - }); - expect(session.actions.map((a) => a.command)).toEqual(['open', 'get']); + // --- The agent's correction for the diverged step IS itself a read (the + // wave-3 E3 shape): `get attrs` on the renamed control, explicitly forced + // into the heal with `--record` since it would otherwise be excluded. --- + ctx.sessionStore.recordAction(storeSessionForTest(ctx.sessionStore, session), { + command, + positionals: command === 'get' ? ['attrs', '@e7'] : [], + flags: { record: true }, + result: { selectorChain: ['id="save-v2"'] }, + interactiveObservation: true, + }); + expect(session.actions.map((a) => a.command)).toEqual(['open', command]); - const leg2 = await runReplayForTest({ - req: baseReq({ - positionals: [filePath], - flags: { saveScript: true, replayFrom: 3, replayPlanDigest: divergence.resume.planDigest }, - }), - sessionName: ctx.sessionName, - logPath: ctx.logPath, - sessionStore: ctx.sessionStore, - invoke, - }); - expect(leg2.ok).toBe(true); - expect(session.actions.map((a) => a.command)).toEqual(['open', 'get', 'click']); + const leg2 = await runReplayForTest({ + req: baseReq({ + positionals: [filePath], + flags: { saveScript: true, replayFrom: 3, replayPlanDigest: divergence.resume.planDigest }, + }), + sessionName: ctx.sessionName, + logPath: ctx.logPath, + sessionStore: ctx.sessionStore, + invoke, + }); + expect(leg2.ok).toBe(true); + expect(session.actions.map((a) => a.command)).toEqual(['open', command, 'click']); - const closeResponse = await handleCloseCommand({ - platformServices: daemonPlatformServicesFixture(), - req: { - token: 't', - session: ctx.sessionName, - command: 'close', - positionals: [], - flags: { saveScript: true }, - }, - sessionName: ctx.sessionName, - logPath: ctx.logPath, - sessionStore: ctx.sessionStore, - leaseRegistry: ctx.leaseRegistry, - }); - expect(closeResponse.ok).toBe(true); + const closeResponse = await handleCloseCommand({ + platformServices: daemonPlatformServicesFixture(), + req: { + token: 't', + session: ctx.sessionName, + command: 'close', + positionals: [], + flags: { saveScript: true }, + }, + sessionName: ctx.sessionName, + logPath: ctx.logPath, + sessionStore: ctx.sessionStore, + leaseRegistry: ctx.leaseRegistry, + }); + expect(closeResponse.ok).toBe(true); - const healedPath = path.join(ctx.root, 'flow.healed.ad'); - const healedScript = fs.readFileSync(healedPath, 'utf8'); - const parsed = parseReplayScriptDetailed(healedScript); - // The --record'ed corrective read DOES land in the heal, resolved to a - // selector (never a bare @ref). - expect(parsed.actions.map((a) => a.command)).toEqual(['open', 'get', 'click', 'close']); - const getAction = parsed.actions.find((a) => a.command === 'get'); - expect(getAction?.positionals).toEqual(['attrs', 'id="save-v2"']); - const bareRefs = parsed.actions.flatMap((a) => a.positionals.filter((p) => p.startsWith('@'))); - expect(bareRefs).toEqual([]); + const healedPath = path.join(ctx.root, 'flow.healed.ad'); + const healedScript = fs.readFileSync(healedPath, 'utf8'); + const parsed = parseReplayScriptDetailed(healedScript); + // The --record'ed corrective read DOES land in the heal, resolved to a + // selector (never a bare @ref). + expect(parsed.actions.map((a) => a.command)).toEqual(['open', command, 'click', 'close']); + const correction = parsed.actions.find((a) => a.command === command); + expect(correction?.positionals).toEqual(command === 'get' ? ['attrs', 'id="save-v2"'] : []); + const bareRefs = parsed.actions.flatMap((a) => a.positionals.filter((p) => p.startsWith('@'))); + expect(bareRefs).toEqual([]); - // --- Replays cleanly in a fresh session too. --- - const fresh = setup('agent-device-repair-record-exclusion-record-fresh-'); - const freshInvoke = makeRecordingReplayInvoke({ - sessionStore: fresh.sessionStore, - sessionName: fresh.sessionName, - }); - const freshRun = await runReplayForTest({ - req: baseReq({ positionals: [healedPath] }), - sessionName: fresh.sessionName, - logPath: fresh.logPath, - sessionStore: fresh.sessionStore, - invoke: freshInvoke, - }); - expect(freshRun.ok).toBe(true); -}); + // --- Replays cleanly in a fresh session too. --- + const fresh = setup('agent-device-repair-record-exclusion-record-fresh-'); + const freshInvoke = makeRecordingReplayInvoke({ + sessionStore: fresh.sessionStore, + sessionName: fresh.sessionName, + }); + const freshRun = await runReplayForTest({ + req: baseReq({ positionals: [healedPath] }), + sessionName: fresh.sessionName, + logPath: fresh.logPath, + sessionStore: fresh.sessionStore, + invoke: freshInvoke, + }); + expect(freshRun.ok).toBe(true); + }, +); test('empty-segment guard: a --from resume refuses with an actionable --record hint when only excluded diagnostic reads happened since the divergence', async () => { 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 result: { found: true }, interactiveObservation: true, }); + ctx.sessionStore.recordAction(storeSessionForTest(ctx.sessionStore, session), { + command: 'snapshot', + positionals: [], + flags: {}, + interactiveObservation: true, + }); expect(session.actions.map((a) => a.command)).toEqual(['open']); const blindResume = await runReplayForTest({ From 0a45b95027c6c887dd6b22a20c88dbc60b148e70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 22:21:33 +0200 Subject: [PATCH 03/15] fix(daemon): scope authored publication intent to observations ADR 0012 decision 6 preserves authored observations through replay provenance. Non-observation wait and diff requests must not acquire authored intent from their false observation classification. Regression proof on 17b30bf736 plus tests: 2 failed, 31 passed (interactive wait/diff received authored intent). With the fix: 33 passed. --- .../__tests__/session-action-recorder.test.ts | 30 ++++++++++++++++++- .../src/core/session-action-recorder.ts | 3 +- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/daemon/src/core/__tests__/session-action-recorder.test.ts b/packages/daemon/src/core/__tests__/session-action-recorder.test.ts index 97fb0b2b6d..1d79c39a16 100644 --- a/packages/daemon/src/core/__tests__/session-action-recorder.test.ts +++ b/packages/daemon/src/core/__tests__/session-action-recorder.test.ts @@ -7,9 +7,37 @@ import { isSessionRecording } from '../session-script-publication-capability.ts' * (`selector-recording.test.ts` covers which commands set `interactiveObservation`). */ import { test, expect } from 'vitest'; -import { recordActionEntry, type RecordActionEntry } from '../session-action-recorder.ts'; +import { isInteractiveObservation, recordActionEntry, type RecordActionEntry } from '../session-action-recorder.ts'; import { authoringPublication, makeIosSession } from '../../__tests__/support/session-factories.ts'; +test.each([ + { command: 'wait', replayPlanStep: false, intent: undefined }, + { command: 'diff', replayPlanStep: false, intent: undefined }, + { command: 'snapshot', replayPlanStep: false, intent: undefined }, + { command: 'snapshot', replayPlanStep: true, intent: 'authored' }, +])( + 'publication intent for $command with replayPlanStep=$replayPlanStep', + ({ command, replayPlanStep, intent }) => { + const session = makeIosSession('default'); + const req = { + token: 't', + session: 'default', + command, + positionals: [], + internal: { replayPlanStep }, + }; + const action = recordActionEntry(session, { + command, + positionals: [], + flags: {}, + interactiveObservation: isInteractiveObservation(req), + }); + + expect(action).toBeDefined(); + expect(action?.publicationIntent).toBe(intent); + }, +); + test('an observation-only action is excluded while repair-armed and no --record is given', () => { const session = makeIosSession('default', { scriptPublication: { diff --git a/packages/daemon/src/core/session-action-recorder.ts b/packages/daemon/src/core/session-action-recorder.ts index 3df853c258..27ec918217 100644 --- a/packages/daemon/src/core/session-action-recorder.ts +++ b/packages/daemon/src/core/session-action-recorder.ts @@ -88,7 +88,8 @@ export function recordActionEntry( result: recordedEntry.result, ...(recordedEntry.flags.record === true ? { publicationIntent: 'explicit-record' as const } - : recordedEntry.interactiveObservation === false + : OBSERVATION_ONLY_COMMANDS.has(recordedEntry.command) && + recordedEntry.interactiveObservation === false ? { publicationIntent: 'authored' as const } : {}), ...(recordedEntry.targetEvidence ? { targetEvidence: recordedEntry.targetEvidence } : {}), From 374edc79e3d46ba48ebf26d3de4d895d3196e9c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 22:25:34 +0200 Subject: [PATCH 04/15] fix(daemon): exclude foreground composite snapshots from recording ADR 0012 decision 6 preserves the authored replay path. The initial snapshot composed by open --foreground is not a source step and must not inherit its recording intent, including a configured record flag. Regression proof on 17b30bf736 plus tests: 2 failed, 13 passed because composite snapshots entered session.actions. With noRecord on the synthetic capture: 15 passed; capture response remains successful. --- .../__tests__/session-open-foreground.test.ts | 32 ++++++++++++++++++- .../internal/session-open-foreground.ts | 2 +- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-foreground.test.ts b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-foreground.test.ts index 68f32315f7..1879c8c52b 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-foreground.test.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/__tests__/session-open-foreground.test.ts @@ -1,6 +1,8 @@ import type { DaemonResponse } from '@agent-device/kernel/contracts'; import { daemonPlatformServicesFixture } from '../../../../__tests__/support/platform-services-fixture.ts'; import { beforeEach, expect, test, vi } from 'vitest'; +import { snapshotRuntimeFixture } from '../../../../__tests__/support/snapshot-runtime-fixture.ts'; +import { repairPublication } from '../../../../__tests__/support/session-factories.ts'; import { fakeCreateCommandSurface } from '../../../../__tests__/support/command-surface-fake.ts'; const dispatchSnapshotViaRuntime = vi.hoisted(() => vi.fn()); @@ -209,7 +211,7 @@ test('attaches the initial INTERACTIVE snapshot by delegating to the existing sn ...req, command: 'snapshot', positionals: [], - flags: { ...req.flags, snapshotInteractiveOnly: true }, + flags: { ...req.flags, snapshotInteractiveOnly: true, noRecord: true }, }, sessionName: 'default', sessionRef: ref, @@ -312,3 +314,31 @@ test('a THROWN snapshot-capture failure never masks the successful open either', ]); } }); + +test.each([undefined, true])( + 'a replayed foreground open does not record its composite snapshot (record=%s)', + async (record) => { + const realSnapshotRuntime = await vi.importActual< + typeof import('../../../snapshot-runtime.ts') + >('../../../snapshot-runtime.ts'); + dispatchSnapshotViaRuntime.mockImplementation(realSnapshotRuntime.dispatchSnapshotViaRuntime); + sessionStore.update(ref, { scriptPublication: repairPublication('complete') }); + + const result = await composeOpenWithInitialSnapshot({ + ...snapshotRuntimeFixture(), + createCommandSurface: fakeCreateCommandSurface, + req: baseRequest({ + flags: { foreground: true, record }, + positionals: ['Demo'], + internal: { replayPlanStep: true }, + }), + ref, + logPath: '/tmp/daemon.log', + sessionStore, + openResponse: okOpenResponse, + }); + + expect(result).toMatchObject({ ok: true, data: { snapshot: { nodes: [] } } }); + expect(sessionStore.requireCurrent(ref).actions).toEqual([]); + }, +); diff --git a/packages/daemon/src/sessions/session-lifecycle/internal/session-open-foreground.ts b/packages/daemon/src/sessions/session-lifecycle/internal/session-open-foreground.ts index ec96f79bd1..bc5e329202 100644 --- a/packages/daemon/src/sessions/session-lifecycle/internal/session-open-foreground.ts +++ b/packages/daemon/src/sessions/session-lifecycle/internal/session-open-foreground.ts @@ -128,7 +128,7 @@ export async function composeOpenWithInitialSnapshot(params: { // The promised composition IS `snapshot -i`: the CLI maps `-i` to // `snapshotInteractiveOnly` (flag-definitions-workflow.ts), which is the // key the snapshot runtime reads as `interactiveOnly`. - flags: { ...req.flags, snapshotInteractiveOnly: true }, + flags: { ...req.flags, snapshotInteractiveOnly: true, noRecord: true }, }, sessionName: ref.address, sessionRef: ref, From 805bffeaf4f34cc91cea184de1611dae0a427296 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 22:28:25 +0200 Subject: [PATCH 05/15] fix(daemon): cover authored snapshots through replay publication ADR 0012 decision 6 requires authored observations to survive the entire repair publication path. Exercise a source snapshot -i through replay, close, healed-file parsing and fresh-session replay. Isolated regression: restore the writer from dacec4ab12 (before snapshot retention) in the 17b30bf736 checkout plus this test. The selected case fails because the healed snapshot is missing (1 failed, 5 skipped). With retention: 1 passed, 5 skipped; full repair-recording suite: 6 passed. --- ...ion-replay-repair-record-exclusion.test.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) 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 b1f866bef2..8c44f5e52e 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 @@ -439,3 +439,54 @@ test('non-repair authoring recording is unchanged: a read in a fresh `open --sav // Recorded normally — no `--record` needed, no repair segment in play. expect(session.actions.map((a) => a.command)).toEqual(['get']); }); + +test('an authored snapshot plan step survives replay and close into the healed file', async () => { + const ctx = setup('agent-device-repair-authored-snapshot-'); + const filePath = writeReplayFile(ctx.root, ['open "Demo" --relaunch', 'snapshot -i', 'close']); + const invoke = makeRecordingReplayInvoke({ + sessionStore: ctx.sessionStore, + sessionName: ctx.sessionName, + }); + const replay = await runReplayForTest({ + req: baseReq({ positionals: [filePath], flags: { saveScript: true } }), + sessionName: ctx.sessionName, + logPath: ctx.logPath, + sessionStore: ctx.sessionStore, + invoke, + }); + expect(replay.ok).toBe(true); + + const closed = await handleCloseCommand({ + platformServices: daemonPlatformServicesFixture(), + req: { + token: 't', + session: ctx.sessionName, + command: 'close', + positionals: [], + flags: { saveScript: true }, + }, + sessionName: ctx.sessionName, + logPath: ctx.logPath, + sessionStore: ctx.sessionStore, + leaseRegistry: ctx.leaseRegistry, + }); + expect(closed.ok).toBe(true); + + const healedPath = path.join(ctx.root, 'flow.healed.ad'); + const parsed = parseReplayScriptDetailed(fs.readFileSync(healedPath, 'utf8')); + expect(parsed.actions.map((action) => action.command)).toEqual(['open', 'snapshot', 'close']); + expect(parsed.actions[1]?.flags.snapshotInteractiveOnly).toBe(true); + + const fresh = setup('agent-device-repair-authored-snapshot-fresh-'); + const freshRun = await runReplayForTest({ + req: baseReq({ positionals: [healedPath] }), + sessionName: fresh.sessionName, + logPath: fresh.logPath, + sessionStore: fresh.sessionStore, + invoke: makeRecordingReplayInvoke({ + sessionStore: fresh.sessionStore, + sessionName: fresh.sessionName, + }), + }); + expect(freshRun.ok).toBe(true); +}); From 3bd540bae145a3285501987fb78390884585b0a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 16 Oct 2026 22:31:17 +0200 Subject: [PATCH 06/15] fix(daemon): retain explicitly recorded snapshots only during repair ADR 0012 decision 6 scopes --record observation retention to repair-armed sessions. Preserve ordinary authoring snapshot exclusion even when project configuration supplies record=true; keep authored source observations and explicit repair corrections. Regression proof on 17b30bf736 plus tests: 1 failed, 1 passed, 39 skipped (ordinary record=true published a diagnostic snapshot). With the fix: 2 passed, 39 skipped. All four focused suites: 95 passed. --- .../__tests__/session-script-writer.test.ts | 20 +++++++++++++++++++ .../daemon/src/core/session-script-writer.ts | 8 +++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/daemon/src/core/__tests__/session-script-writer.test.ts b/packages/daemon/src/core/__tests__/session-script-writer.test.ts index ce8702b73a..3558dafb38 100644 --- a/packages/daemon/src/core/__tests__/session-script-writer.test.ts +++ b/packages/daemon/src/core/__tests__/session-script-writer.test.ts @@ -863,3 +863,23 @@ test('#1533: an ARMED authoring recording is untouched by the abort gate', () => const { parsed } = writeAndParse(writer, session); expect(parsed.actions.map((a) => a.command)).toEqual(['click']); }); + +test.each([undefined, true])( + 'ordinary authoring strips diagnostic snapshots with effective record=%s', + (record) => { + const root = mkdtempForTestSync('agent-device-script-writer-authoring-snapshot-'); + const writer = new SessionScriptWriter(path.join(root, 'sessions')); + const session = makeAuthoringSession('default', { + actions: [action({ command: 'open', positionals: ['Demo'] })], + }); + recordActionEntry(session, { + command: 'snapshot', + positionals: [], + flags: { record, snapshotInteractiveOnly: true }, + interactiveObservation: true, + }); + + const { parsed } = writeAndParse(writer, session); + expect(parsed.actions.map((entry) => entry.command)).toEqual(['open']); + }, +); diff --git a/packages/daemon/src/core/session-script-writer.ts b/packages/daemon/src/core/session-script-writer.ts index ac1f2e0f2c..2f76acd75c 100644 --- a/packages/daemon/src/core/session-script-writer.ts +++ b/packages/daemon/src/core/session-script-writer.ts @@ -312,7 +312,13 @@ function buildOptimizedActions( const relevantActions = session.actions.slice(boundary ?? 0); const optimized: SessionAction[] = []; for (const action of relevantActions) { - if (action.command === 'snapshot' && action.publicationIntent === undefined) continue; + if ( + action.command === 'snapshot' && + action.publicationIntent !== 'authored' && + !(repairArmed && action.publicationIntent === 'explicit-record') + ) { + continue; + } const optimizedAction = optimizeSelectorChainAction(action); if (optimizedAction) { optimized.push(optimizedAction); From 0335a2d6b00b568f5c4e51ab61a13227c533bb15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 07:58:40 +0200 Subject: [PATCH 07/15] fix: simplify snapshot intent and prove disposal lock retention Address https://github.com/callstack/agent-device/pull/3760#issuecomment-6226107224. Read explicit recording from the existing action flags, retain authored provenance, add the Unreleased notes, and synchronize lock tests with queue insertion. --- CHANGELOG.md | 2 ++ packages/contracts/src/session-action.ts | 4 ++-- .../request-runtime-binding-router.test.ts | 22 ++++++++++++++++--- .../__tests__/session-action-recorder.test.ts | 2 ++ .../__tests__/session-script-writer.test.ts | 12 ++++++---- .../src/core/session-action-recorder.ts | 11 +++++----- .../daemon/src/core/session-script-writer.ts | 2 +- 7 files changed, 39 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a9c8b4468..8c4f6ca7b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ - Behavior change: the CLI and MCP now call `ensureDaemon()` explicitly once their config is resolved, then attach to the daemon it leaves. A command that finds a daemon from another version or build replaces it only if the daemon reports no request, session, repair session or live lease. A resumable upload still streaming and a host human-control hold that names no lease are not counted, so a replacement can end them. A daemon that answers nothing on any transport is retired after repeated failed liveness probes, which cannot show it idle. A busy one fails the command with `details.reason: 'daemon-busy'` instead of being replaced with its sessions, and a daemon from a release that predates quiescing fails it with `'daemon-incompatible'`; stop either with `agent-device daemon stop --state-dir `. The takeover notice moves from `Replacing daemon … : ` to `Replaced idle daemon …` (an incompatible daemon) or `Replaced unreachable daemon …` (one that answered nothing). A request that times out no longer retires an unresponsive daemon from the CLI or MCP; the error names `agent-device daemon stop --state-dir `. A one-shot `replay` or `test` with an explicit `--state-dir` leaves the daemon it started running until its idle reap instead of stopping it. Remote `--daemon-base-url` connections and replay on its private ephemeral daemon are unchanged. Node.js clients keep `daemonLifecycle: 'managed'` until 0.22. - Add `ensureDaemon({ stateDir })` and the client config `daemonLifecycle: 'attach' | 'managed'`. An `attach` client connects to the daemon registered in the state directory and never starts, replaces or kills one, including after a request times out. It fails with `details.reason: 'daemon-not-running'` (the hint points at `ensureDaemon()`) or `'daemon-incompatible'` (both identities in `details`, the hint names `agent-device daemon stop`). `ensureDaemon()` starts a missing daemon and replaces an incompatible one that answers only through an authenticated quiesce request the daemon answers itself (a resumable upload still streaming and a human-control hold that names no lease are not counted, so a replacement can end them): it refuses with `daemon-busy` while any request is in flight or any session, repair session or live lease exists, and a daemon from a release before this one is never replaced. A registered daemon that answers nothing is retired, as is one that times out on the quiesce request and then fails repeated liveness probes; neither can be asked. A remote `daemonBaseUrl` connection never starts or retires a local daemon in either mode. `managed` stays the default and behaves as before until 0.22. - `lease_allocate` (and `client.leases.allocate()`) with a `leaseProvider` that no registered runtime claims now refuses before recording a lease, with `UNSUPPORTED_OPERATION`, `error.details.reason: provider-not-registered`, `details.provider`, `details.registeredProviders`, and `details.failedPlugins` naming a plugin that failed to register. Previously a plugin provider that the daemon never loaded passed admission and recorded a lease with no device behind it. Leases with no provider (a `leaseBackend` alone included), `proxy` and `cloud` need no runtime and still allocate. Restart the daemon with the provider configured, then retry. +- Healed scripts retain authored snapshot steps and snapshots explicitly included with `--record` during repair. Diagnostic snapshots remain excluded. +- Requests that share a session or device now queue until the preceding request finishes disposing its runtime binding and releasing its device claim, including after failure or cancellation. - Add `uninstall ` and `client.apps.uninstall({ app })` to remove an installed app on adb-backed Android targets, iOS simulators, and CoreDevice iOS devices. Both report `wasInstalled`. A package name or bundle id that is not installed succeeds with `wasInstalled: false`. Other targets, Appium and Limrun sessions included, refuse it before anything runs. - Add `settings mobile-data on|off` for adb-backed Android targets. It switches mobile data through `svc data` and reports the `mobileData` state stored in the global `mobile_data` row; multi-SIM read-back is not covered yet. Every other target refuses it before anything runs, and `--app` is refused because the switch is device-wide. - On iOS, a `fill` that runs under a degraded XCTest snapshot channel now aims at a point, including ref, selector and replay fills. When the runner identifies no text input under that point, it refuses with `NO_TEXT_INPUT_AT_POINT` (`error.details.reason: no_text_input_at_point`, `dispatched: no`) before dispatching its focus tap, and no field changes; when the input lookup itself cannot answer, it refuses with `TEXT_INPUT_PROBE_UNAVAILABLE` (`error.details.reason: text_input_probe_unavailable`). Previously such a fill cleared and typed through the app-level first responder, so it could rewrite a field the caller never named, or report a clear failure after emptying it. A ref or selector fill whose rect centre misses the real field (an outer container, a non-input node) now needs a point inside the input's own rect. diff --git a/packages/contracts/src/session-action.ts b/packages/contracts/src/session-action.ts index 79185d17de..13c25e9355 100644 --- a/packages/contracts/src/session-action.ts +++ b/packages/contracts/src/session-action.ts @@ -28,8 +28,8 @@ export type SessionAction = { noRecord?: boolean; }; result?: Record; - /** Retain authored plan observations and explicitly recorded corrections during publication. */ - publicationIntent?: 'authored' | 'explicit-record'; + /** Retain authored plan observations during publication. */ + publicationIntent?: 'authored'; /** * ADR 0012 decision 3: parsed or record-time-computed `target-v1` * evidence, written as a comment immediately before this action's line. diff --git a/packages/daemon/src/core/__tests__/request-runtime-binding-router.test.ts b/packages/daemon/src/core/__tests__/request-runtime-binding-router.test.ts index eb2b59ca16..85474e876a 100644 --- a/packages/daemon/src/core/__tests__/request-runtime-binding-router.test.ts +++ b/packages/daemon/src/core/__tests__/request-runtime-binding-router.test.ts @@ -23,6 +23,7 @@ import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; import { LeaseRegistry } from '../../resources/lease-registry.ts'; import { createRequestHandler } from '../../__tests__/support/test-device-runtime-gateway.ts'; import { fakeCreateCommandSurface } from '../../__tests__/support/command-surface-fake.ts'; +import { getLeaseRegistryExecutionLocks } from '../../server/request-execution-scope.ts'; test('request binding disposes once after response while adopted app-log handle survives', async () => { const runtime = makeGateway(); @@ -61,7 +62,7 @@ for (const outcome of ['success', 'throw', 'cancellation'] as const) { for (const key of ['session', 'device'] as const) { test(`binding cleanup holds the ${key} execution lock after ${outcome}`, async () => { const runtime = makeGateway(); - const { handler, sessionStore } = makeHandler(runtime.gateway); + const { handler, sessionStore, leaseRegistry } = makeHandler(runtime.gateway); const current = sessionStore.get('session')!; sessionStore.publish('other-session', { ...current, name: 'other-session', actions: [] }); const requestId = `cleanup-${key}-${outcome}`; @@ -89,17 +90,30 @@ for (const outcome of ['success', 'throw', 'cancellation'] as const) { }); const first = handler({ ...request(['doctor']), meta: { requestId } }); await cleanupStarted; + const locks = getLeaseRegistryExecutionLocks(leaseRegistry); + const sharedKey = key === 'session' ? 'session:session' : 'device:emulator-5554'; + let markQueued: (entry: { previous: Promise | undefined }) => void = () => {}; + const queued = new Promise<{ previous: Promise | undefined }>((resolve) => { + markQueued = resolve; + }); + const setLock = locks.set.bind(locks); + const queueSpy = vi.spyOn(locks, 'set').mockImplementation((lockKey, completion) => { + if (lockKey === sharedKey) markQueued({ previous: locks.get(lockKey) }); + return setLock(lockKey, completion); + }); const second = handler({ ...request(['doctor']), session: key === 'session' ? 'session' : 'other-session', meta: { requestId: `${requestId}-queued` }, }); try { - await new Promise((resolve) => setImmediate(resolve)); + expect((await queued).previous).toBeDefined(); expect(runtime.bind).toHaveBeenCalledTimes(1); } finally { + queueSpy.mockRestore(); finishCleanup(); clearRequestCanceled(requestId); + await Promise.all([first, second]); } const [firstResponse, secondResponse] = await Promise.all([first, second]); expect(firstResponse.ok).toBe(outcome === 'success'); @@ -118,6 +132,7 @@ for (const outcome of ['success', 'throw', 'cancellation'] as const) { function makeHandler(gateway: DeviceRuntimeGateway) { const sessionStore = makeSessionStore('request-runtime-binding-router-'); + const leaseRegistry = new LeaseRegistry(); sessionStore.publish('session', { name: 'session', device: { platform: 'android', id: 'emulator-5554', name: 'Pixel', kind: 'emulator' }, @@ -127,12 +142,13 @@ function makeHandler(gateway: DeviceRuntimeGateway) { }); return { sessionStore, + leaseRegistry, handler: createRequestHandler({ createCommandSurface: fakeCreateCommandSurface, logPath: '/tmp/daemon.log', token: 'token', sessionStore, - leaseRegistry: new LeaseRegistry(), + leaseRegistry, deviceInventoryGateways: createTestDeviceInventoryGateways(), deviceRuntimeGateway: gateway, trackDownloadableArtifact: () => 'artifact', diff --git a/packages/daemon/src/core/__tests__/session-action-recorder.test.ts b/packages/daemon/src/core/__tests__/session-action-recorder.test.ts index 1d79c39a16..7ec961b36b 100644 --- a/packages/daemon/src/core/__tests__/session-action-recorder.test.ts +++ b/packages/daemon/src/core/__tests__/session-action-recorder.test.ts @@ -75,6 +75,8 @@ test('--record forces an observation-only action through while repair-armed', () interactiveObservation: true, }); expect(action).toBeDefined(); + expect(action?.flags.record).toBe(true); + expect(action?.publicationIntent).toBeUndefined(); expect(session.actions.map((a) => a.command)).toEqual(['get']); }); diff --git a/packages/daemon/src/core/__tests__/session-script-writer.test.ts b/packages/daemon/src/core/__tests__/session-script-writer.test.ts index 3558dafb38..a4af093e42 100644 --- a/packages/daemon/src/core/__tests__/session-script-writer.test.ts +++ b/packages/daemon/src/core/__tests__/session-script-writer.test.ts @@ -864,9 +864,13 @@ test('#1533: an ARMED authoring recording is untouched by the abort gate', () => expect(parsed.actions.map((a) => a.command)).toEqual(['click']); }); -test.each([undefined, true])( - 'ordinary authoring strips diagnostic snapshots with effective record=%s', - (record) => { +test.each([ + { record: undefined, replayPlanStep: false }, + { record: true, replayPlanStep: false }, + { record: true, replayPlanStep: true }, +])( + 'ordinary authoring strips snapshots with record=$record and replayPlanStep=$replayPlanStep', + ({ record, replayPlanStep }) => { const root = mkdtempForTestSync('agent-device-script-writer-authoring-snapshot-'); const writer = new SessionScriptWriter(path.join(root, 'sessions')); const session = makeAuthoringSession('default', { @@ -876,7 +880,7 @@ test.each([undefined, true])( command: 'snapshot', positionals: [], flags: { record, snapshotInteractiveOnly: true }, - interactiveObservation: true, + interactiveObservation: !replayPlanStep, }); const { parsed } = writeAndParse(writer, session); diff --git a/packages/daemon/src/core/session-action-recorder.ts b/packages/daemon/src/core/session-action-recorder.ts index 27ec918217..10920f694b 100644 --- a/packages/daemon/src/core/session-action-recorder.ts +++ b/packages/daemon/src/core/session-action-recorder.ts @@ -86,12 +86,11 @@ export function recordActionEntry( runtime: recordedEntry.runtime, flags: sanitizeFlags(recordedEntry.flags), result: recordedEntry.result, - ...(recordedEntry.flags.record === true - ? { publicationIntent: 'explicit-record' as const } - : OBSERVATION_ONLY_COMMANDS.has(recordedEntry.command) && - recordedEntry.interactiveObservation === false - ? { publicationIntent: 'authored' as const } - : {}), + ...(recordedEntry.flags.record !== true && + OBSERVATION_ONLY_COMMANDS.has(recordedEntry.command) && + recordedEntry.interactiveObservation === false + ? { publicationIntent: 'authored' as const } + : {}), ...(recordedEntry.targetEvidence ? { targetEvidence: recordedEntry.targetEvidence } : {}), ...(recordedEntry.targetEvidences ? { targetEvidences: recordedEntry.targetEvidences } : {}), }; diff --git a/packages/daemon/src/core/session-script-writer.ts b/packages/daemon/src/core/session-script-writer.ts index 2f76acd75c..a5ee081102 100644 --- a/packages/daemon/src/core/session-script-writer.ts +++ b/packages/daemon/src/core/session-script-writer.ts @@ -315,7 +315,7 @@ function buildOptimizedActions( if ( action.command === 'snapshot' && action.publicationIntent !== 'authored' && - !(repairArmed && action.publicationIntent === 'explicit-record') + !(repairArmed && action.flags.record === true) ) { continue; } From 77f6751745bc4ab3847d6d4989676f5c7d231fa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 09:31:33 +0200 Subject: [PATCH 08/15] fix(replay): preserve explicit recording intent at ingress Distinguish explicit CLI/MCP/SDK corrections from project record defaults before recording and publishing repair observations. Retain authored provenance even when record is also set. Review thread: https://github.com/callstack/agent-device/pull/3760#discussion_r4301665457 --- CHANGELOG.md | 2 +- .../src/flag-definitions-action.ts | 2 +- packages/contracts/src/command-flags.ts | 2 + packages/contracts/src/session-action.ts | 4 +- .../core/__tests__/selector-recording.test.ts | 21 +++- .../__tests__/session-action-recorder.test.ts | 45 +++++---- .../__tests__/session-script-writer.test.ts | 19 +++- .../src/core/session-action-recorder.ts | 12 ++- .../daemon/src/core/session-script-writer.ts | 2 +- ...ion-replay-repair-record-exclusion.test.ts | 2 +- src/__tests__/cli-record-publication.test.ts | 95 +++++++++++++++++++ src/agent-device-client.ts | 4 +- src/cli.ts | 1 + src/commands/command-flags.ts | 4 + src/mcp/command-tools.ts | 11 ++- .../replay-repair-record-exclusion.test.ts | 12 ++- website/docs/docs/replay-e2e.md | 2 + 17 files changed, 195 insertions(+), 45 deletions(-) create mode 100644 src/__tests__/cli-record-publication.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c4f6ca7b3..e2437695c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ - Behavior change: the CLI and MCP now call `ensureDaemon()` explicitly once their config is resolved, then attach to the daemon it leaves. A command that finds a daemon from another version or build replaces it only if the daemon reports no request, session, repair session or live lease. A resumable upload still streaming and a host human-control hold that names no lease are not counted, so a replacement can end them. A daemon that answers nothing on any transport is retired after repeated failed liveness probes, which cannot show it idle. A busy one fails the command with `details.reason: 'daemon-busy'` instead of being replaced with its sessions, and a daemon from a release that predates quiescing fails it with `'daemon-incompatible'`; stop either with `agent-device daemon stop --state-dir `. The takeover notice moves from `Replacing daemon … : ` to `Replaced idle daemon …` (an incompatible daemon) or `Replaced unreachable daemon …` (one that answered nothing). A request that times out no longer retires an unresponsive daemon from the CLI or MCP; the error names `agent-device daemon stop --state-dir `. A one-shot `replay` or `test` with an explicit `--state-dir` leaves the daemon it started running until its idle reap instead of stopping it. Remote `--daemon-base-url` connections and replay on its private ephemeral daemon are unchanged. Node.js clients keep `daemonLifecycle: 'managed'` until 0.22. - Add `ensureDaemon({ stateDir })` and the client config `daemonLifecycle: 'attach' | 'managed'`. An `attach` client connects to the daemon registered in the state directory and never starts, replaces or kills one, including after a request times out. It fails with `details.reason: 'daemon-not-running'` (the hint points at `ensureDaemon()`) or `'daemon-incompatible'` (both identities in `details`, the hint names `agent-device daemon stop`). `ensureDaemon()` starts a missing daemon and replaces an incompatible one that answers only through an authenticated quiesce request the daemon answers itself (a resumable upload still streaming and a human-control hold that names no lease are not counted, so a replacement can end them): it refuses with `daemon-busy` while any request is in flight or any session, repair session or live lease exists, and a daemon from a release before this one is never replaced. A registered daemon that answers nothing is retired, as is one that times out on the quiesce request and then fails repeated liveness probes; neither can be asked. A remote `daemonBaseUrl` connection never starts or retires a local daemon in either mode. `managed` stays the default and behaves as before until 0.22. - `lease_allocate` (and `client.leases.allocate()`) with a `leaseProvider` that no registered runtime claims now refuses before recording a lease, with `UNSUPPORTED_OPERATION`, `error.details.reason: provider-not-registered`, `details.provider`, `details.registeredProviders`, and `details.failedPlugins` naming a plugin that failed to register. Previously a plugin provider that the daemon never loaded passed admission and recorded a lease with no device behind it. Leases with no provider (a `leaseBackend` alone included), `proxy` and `cloud` need no runtime and still allocate. Restart the daemon with the provider configured, then retry. -- Healed scripts retain authored snapshot steps and snapshots explicitly included with `--record` during repair. Diagnostic snapshots remain excluded. +- Healed scripts retain authored snapshot steps and snapshots explicitly included with `--record` during repair. Diagnostic snapshots remain excluded, including when project config sets `record: true`. - Requests that share a session or device now queue until the preceding request finishes disposing its runtime binding and releasing its device claim, including after failure or cancellation. - Add `uninstall ` and `client.apps.uninstall({ app })` to remove an installed app on adb-backed Android targets, iOS simulators, and CoreDevice iOS devices. Both report `wasInstalled`. A package name or bundle id that is not installed succeeds with `wasInstalled: false`. Other targets, Appium and Limrun sessions included, refuse it before anything runs. - Add `settings mobile-data on|off` for adb-backed Android targets. It switches mobile data through `svc data` and reports the `mobileData` state stored in the global `mobile_data` row; multi-SIM read-back is not covered yet. Every other target refuses it before anything runs, and `--app` is refused because the switch is device-wide. diff --git a/packages/command-registry/src/flag-definitions-action.ts b/packages/command-registry/src/flag-definitions-action.ts index 9df695c6ec..0fcea104dd 100644 --- a/packages/command-registry/src/flag-definitions-action.ts +++ b/packages/command-registry/src/flag-definitions-action.ts @@ -432,7 +432,7 @@ export const ACTION_FLAG_DEFINITIONS: readonly FlagDefinition[] = [ // the synopsis stays the invocation they are choosing between (#1271 stage 2). usageHidden: true, usageDescription: - 'Force-record this action even though its command is observation-only and would otherwise be excluded from a repair-armed heal by default (mutually exclusive with --no-record)', + 'Force-record this action into a repair-armed heal for this invocation; a project-config default does not opt in (mutually exclusive with --no-record)', projectConfig: true, recorded: true, }, diff --git a/packages/contracts/src/command-flags.ts b/packages/contracts/src/command-flags.ts index 7b56cc0e6d..57f6b84ec3 100644 --- a/packages/contracts/src/command-flags.ts +++ b/packages/contracts/src/command-flags.ts @@ -26,6 +26,8 @@ export type MaestroRuntimeFlags = { }; export type CommandFlags = Omit & { + /** Per-action recording opt-in established before project defaults lose their provenance. */ + recordingIntent?: 'explicit-record'; batchSteps?: DaemonBatchStep[]; clearAppState?: boolean; launchArgs?: string[]; diff --git a/packages/contracts/src/session-action.ts b/packages/contracts/src/session-action.ts index 13c25e9355..4a4b2a79da 100644 --- a/packages/contracts/src/session-action.ts +++ b/packages/contracts/src/session-action.ts @@ -28,8 +28,8 @@ export type SessionAction = { noRecord?: boolean; }; result?: Record; - /** Retain authored plan observations during publication. */ - publicationIntent?: 'authored'; + /** Retain authored plan observations or explicitly recorded repair observations. */ + publicationIntent?: 'authored' | 'explicit-record'; /** * ADR 0012 decision 3: parsed or record-time-computed `target-v1` * evidence, written as a comment immediately before this action's line. diff --git a/packages/daemon/src/core/__tests__/selector-recording.test.ts b/packages/daemon/src/core/__tests__/selector-recording.test.ts index f82fbf976e..81ec80c403 100644 --- a/packages/daemon/src/core/__tests__/selector-recording.test.ts +++ b/packages/daemon/src/core/__tests__/selector-recording.test.ts @@ -92,9 +92,24 @@ test('--record forces get/is/find through even while repair-armed', () => { }); store.publish('default', session); - recordIfSession(store, store.lookup('default'), req('get', { record: true }), {}); - recordIfSession(store, store.lookup('default'), req('is', { record: true }), {}); - recordIfSession(store, store.lookup('default'), req('find', { record: true }), {}); + recordIfSession( + store, + store.lookup('default'), + req('get', { record: true, recordingIntent: 'explicit-record' }), + {}, + ); + recordIfSession( + store, + store.lookup('default'), + req('is', { record: true, recordingIntent: 'explicit-record' }), + {}, + ); + recordIfSession( + store, + store.lookup('default'), + req('find', { record: true, recordingIntent: 'explicit-record' }), + {}, + ); expect(store.get('default')!.actions.map((a) => a.command)).toEqual(['get', 'is', 'find']); }); diff --git a/packages/daemon/src/core/__tests__/session-action-recorder.test.ts b/packages/daemon/src/core/__tests__/session-action-recorder.test.ts index 7ec961b36b..a23fc98059 100644 --- a/packages/daemon/src/core/__tests__/session-action-recorder.test.ts +++ b/packages/daemon/src/core/__tests__/session-action-recorder.test.ts @@ -38,25 +38,28 @@ test.each([ }, ); -test('an observation-only action is excluded while repair-armed and no --record is given', () => { - const session = makeIosSession('default', { - scriptPublication: { - kind: 'repair', - status: 'armed', - target: { kind: 'default', force: false }, - boundary: 0, - }, - }); - const action = recordActionEntry(session, { - command: 'get', - positionals: ['attrs', 'id="save"'], - flags: {}, - result: {}, - interactiveObservation: true, - }); - expect(action).toBeUndefined(); - expect(session.actions).toHaveLength(0); -}); +test.each([{}, { record: true }])( + 'an observation is excluded without explicit intent, flags=%j', + (flags) => { + const session = makeIosSession('default', { + scriptPublication: { + kind: 'repair', + status: 'armed', + target: { kind: 'default', force: false }, + boundary: 0, + }, + }); + const action = recordActionEntry(session, { + command: 'get', + positionals: ['attrs', 'id="save"'], + flags, + result: {}, + interactiveObservation: true, + }); + expect(action).toBeUndefined(); + expect(session.actions).toHaveLength(0); + }, +); test('--record forces an observation-only action through while repair-armed', () => { const session = makeIosSession('default', { @@ -70,13 +73,13 @@ test('--record forces an observation-only action through while repair-armed', () const action = recordActionEntry(session, { command: 'get', positionals: ['attrs', 'id="save"'], - flags: { record: true }, + flags: { record: true, recordingIntent: 'explicit-record' }, result: {}, interactiveObservation: true, }); expect(action).toBeDefined(); expect(action?.flags.record).toBe(true); - expect(action?.publicationIntent).toBeUndefined(); + expect(action?.publicationIntent).toBe('explicit-record'); expect(session.actions.map((a) => a.command)).toEqual(['get']); }); diff --git a/packages/daemon/src/core/__tests__/session-script-writer.test.ts b/packages/daemon/src/core/__tests__/session-script-writer.test.ts index a4af093e42..d8ec855fd3 100644 --- a/packages/daemon/src/core/__tests__/session-script-writer.test.ts +++ b/packages/daemon/src/core/__tests__/session-script-writer.test.ts @@ -132,7 +132,11 @@ test.each(['authored', 'explicit-record'] as const)( positionals: [], flags: intent === 'explicit-record' - ? { record: true, snapshotInteractiveOnly: true } + ? { + record: true, + recordingIntent: 'explicit-record' as const, + snapshotInteractiveOnly: true, + } : { snapshotInteractiveOnly: true }, ...(intent === 'authored' ? { internal: { replayPlanStep: true } } : {}), }; @@ -156,6 +160,7 @@ test('a boundary-sliced script still strips diagnostic snapshot actions', () => actions: [ action({ command: 'open', positionals: ['Demo'] }), action({ command: 'snapshot', positionals: [] }), + action({ command: 'snapshot', positionals: [], flags: { record: true } }), action({ command: 'click', positionals: ['label="Save"'] }), ], }); @@ -869,7 +874,7 @@ test.each([ { record: true, replayPlanStep: false }, { record: true, replayPlanStep: true }, ])( - 'ordinary authoring strips snapshots with record=$record and replayPlanStep=$replayPlanStep', + 'ordinary authoring retains only authored snapshots with record=$record and replayPlanStep=$replayPlanStep', ({ record, replayPlanStep }) => { const root = mkdtempForTestSync('agent-device-script-writer-authoring-snapshot-'); const writer = new SessionScriptWriter(path.join(root, 'sessions')); @@ -879,11 +884,17 @@ test.each([ recordActionEntry(session, { command: 'snapshot', positionals: [], - flags: { record, snapshotInteractiveOnly: true }, + flags: { + record, + recordingIntent: record ? 'explicit-record' : undefined, + snapshotInteractiveOnly: true, + }, interactiveObservation: !replayPlanStep, }); const { parsed } = writeAndParse(writer, session); - expect(parsed.actions.map((entry) => entry.command)).toEqual(['open']); + expect(parsed.actions.map((entry) => entry.command)).toEqual( + replayPlanStep ? ['open', 'snapshot'] : ['open'], + ); }, ); diff --git a/packages/daemon/src/core/session-action-recorder.ts b/packages/daemon/src/core/session-action-recorder.ts index 10920f694b..442bd92494 100644 --- a/packages/daemon/src/core/session-action-recorder.ts +++ b/packages/daemon/src/core/session-action-recorder.ts @@ -86,10 +86,12 @@ export function recordActionEntry( runtime: recordedEntry.runtime, flags: sanitizeFlags(recordedEntry.flags), result: recordedEntry.result, - ...(recordedEntry.flags.record !== true && - OBSERVATION_ONLY_COMMANDS.has(recordedEntry.command) && - recordedEntry.interactiveObservation === false - ? { publicationIntent: 'authored' as const } + ...(OBSERVATION_ONLY_COMMANDS.has(recordedEntry.command) + ? recordedEntry.interactiveObservation === false + ? { publicationIntent: 'authored' as const } + : recordedEntry.flags.recordingIntent === 'explicit-record' + ? { publicationIntent: 'explicit-record' as const } + : {} : {}), ...(recordedEntry.targetEvidence ? { targetEvidence: recordedEntry.targetEvidence } : {}), ...(recordedEntry.targetEvidences ? { targetEvidences: recordedEntry.targetEvidences } : {}), @@ -365,7 +367,7 @@ function isExcludedRepairSegmentObservation( ): boolean { if (!entry.interactiveObservation) return false; if (repairSessionBoundary(session) === undefined) return false; - return entry.flags?.record !== true; + return entry.flags.recordingIntent !== 'explicit-record'; } // The keys a recorded action carries derive from each flag declaration's `recorded` diff --git a/packages/daemon/src/core/session-script-writer.ts b/packages/daemon/src/core/session-script-writer.ts index a5ee081102..2f76acd75c 100644 --- a/packages/daemon/src/core/session-script-writer.ts +++ b/packages/daemon/src/core/session-script-writer.ts @@ -315,7 +315,7 @@ function buildOptimizedActions( if ( action.command === 'snapshot' && action.publicationIntent !== 'authored' && - !(repairArmed && action.flags.record === true) + !(repairArmed && action.publicationIntent === 'explicit-record') ) { continue; } 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 8c44f5e52e..d632e19c0c 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 @@ -301,7 +301,7 @@ test.each(['get', 'snapshot'])( ctx.sessionStore.recordAction(storeSessionForTest(ctx.sessionStore, session), { command, positionals: command === 'get' ? ['attrs', '@e7'] : [], - flags: { record: true }, + flags: { record: true, recordingIntent: 'explicit-record' }, result: { selectorChain: ['id="save-v2"'] }, interactiveObservation: true, }); diff --git a/src/__tests__/cli-record-publication.test.ts b/src/__tests__/cli-record-publication.test.ts new file mode 100644 index 0000000000..52898ba0f8 --- /dev/null +++ b/src/__tests__/cli-record-publication.test.ts @@ -0,0 +1,95 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { expect, test } from 'vitest'; +import type { CommandFlags } from '@agent-device/contracts/command'; +import type { AgentDeviceDaemonTransport } from '@agent-device/contracts/client'; +import { parseReplayScriptDetailed } from '@agent-device/ad-script'; +import { SessionStore } from '@agent-device/daemon/testing/session-store'; +import { isInteractiveObservation } from '@agent-device/daemon/testing/session-action-recorder'; +import { makeRepairCompleteSession } from '@agent-device/daemon/testing/__tests__/support/session-factories'; +import { runCliCapture } from './cli-capture.ts'; +import { makeTempWorkspace } from './cli-config-fixtures.ts'; +import { createAgentDeviceClient } from '../agent-device-client.ts'; +import { createCommandToolExecutor } from '../mcp/command-tools.ts'; +import { installIsolatedCliTestEnv } from './cli-test-env.ts'; + +function repairPublication(root: string) { + const store = new SessionStore(path.join(root, 'sessions')); + const session = makeRepairCompleteSession('default', { + actions: [{ ts: 0, command: 'open', positionals: ['Demo'], flags: {} }], + }); + const ref = store.publish(session.name, session); + const transport: AgentDeviceDaemonTransport = async (req) => { + store.recordAction(ref, { + command: req.command, + positionals: req.positionals, + flags: req.flags as CommandFlags, + interactiveObservation: isInteractiveObservation({ + ...req, + token: 'test', + session: 'default', + }), + }); + return { ok: true, data: { nodes: [] } }; + }; + return { + session, + transport, + commands: () => { + const written = store.writeSessionLog(ref); + if (!written.written) throw new Error('expected a healed script'); + return parseReplayScriptDetailed(fs.readFileSync(written.path, 'utf8')).actions.map( + (action) => action.command, + ); + }, + }; +} + +test.each([false, true])( + 'CLI project record default retains a repair snapshot only with explicit opt-in=%s', + async (explicit) => { + const { root, home, project } = makeTempWorkspace(); + fs.writeFileSync(path.join(project, 'agent-device.json'), JSON.stringify({ record: true })); + const publication = repairPublication(root); + const result = await runCliCapture(['snapshot', '--json', ...(explicit ? ['--record'] : [])], { + cwd: project, + env: { HOME: home }, + sendToDaemon: publication.transport, + }); + expect(result.calls).toHaveLength(1); + expect(result.calls[0]?.flags?.record).toBe(true); + expect(publication.commands()).toEqual(explicit ? ['open', 'snapshot'] : ['open']); + }, +); + +test.each([false, true])( + 'MCP project record default retains a repair snapshot only with explicit opt-in=%s', + async (explicit) => { + const { root, home, project } = makeTempWorkspace(); + fs.writeFileSync(path.join(project, 'agent-device.json'), JSON.stringify({ record: true })); + const publication = repairPublication(root); + const restoreEnv = installIsolatedCliTestEnv({ HOME: home }); + const previousCwd = process.cwd(); + process.chdir(project); + try { + const executor = createCommandToolExecutor({ + createClient: (config, ingress) => + createAgentDeviceClient(config, { ...ingress, transport: publication.transport }), + }); + const result = await executor.execute('snapshot', explicit ? { record: true } : {}); + expect(result.isError).toBe(false); + expect(publication.commands()).toEqual(explicit ? ['open', 'snapshot'] : ['open']); + } finally { + process.chdir(previousCwd); + restoreEnv(); + } + }, +); + +test('SDK per-call record explicitly retains a corrective snapshot', async () => { + const { root } = makeTempWorkspace(); + const publication = repairPublication(root); + const client = createAgentDeviceClient({}, { transport: publication.transport }); + await client.capture.snapshot({ record: true }); + expect(publication.commands()).toEqual(['open', 'snapshot']); +}); diff --git a/src/agent-device-client.ts b/src/agent-device-client.ts index 4bc838f0a0..318dad7d3d 100644 --- a/src/agent-device-client.ts +++ b/src/agent-device-client.ts @@ -82,7 +82,7 @@ import { createRequestGuard } from './daemon-client/daemon-client-transport.ts'; export function createAgentDeviceClient( config: AgentDeviceClientConfig = {}, - deps: { transport?: AgentDeviceDaemonTransport } = {}, + deps: { transport?: AgentDeviceDaemonTransport; recordSource?: 'explicit' | 'default' } = {}, ): AgentDeviceClient { const transport = deps.transport ?? sendToDaemon; // A typo in a JSON or JS config must not fall through to the default and manage a daemon. @@ -113,7 +113,7 @@ export function createAgentDeviceClient( command, positionals, ...(input ? { input } : {}), - flags: buildRequestFlags(merged, metadataFlags), + flags: buildRequestFlags(merged, metadataFlags, deps.recordSource), runtime: merged.runtime, meta: { ...buildMeta(merged), requestId }, }; diff --git a/src/cli.ts b/src/cli.ts index 9a50fbf6fe..ea1b9ab2ba 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -190,6 +190,7 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): const replayTestReporterRuntime = await createReplayReporterForTest(ctx); const commandProgress = createCommandProgressState(); const client = createAgentDeviceClient(buildClientConfig(ctx), { + recordSource: ctx.explicitFlagKeys.has('record') ? 'explicit' : 'default', transport: createCliDaemonTransport({ command, flags: ctx.effectiveFlags, diff --git a/src/commands/command-flags.ts b/src/commands/command-flags.ts index e2c103d857..49b15cb6d0 100644 --- a/src/commands/command-flags.ts +++ b/src/commands/command-flags.ts @@ -128,10 +128,14 @@ function buildFlags(options: InternalRequestOptions): CommandFlags { export function buildRequestFlags( options: InternalRequestOptions, metadataFlags: Partial | undefined, + recordSource: 'explicit' | 'default' = 'explicit', ): CommandFlags { return { ...buildFlags(options), ...metadataFlags, + ...(options.record === true && recordSource === 'explicit' + ? { recordingIntent: 'explicit-record' as const } + : {}), }; } diff --git a/src/mcp/command-tools.ts b/src/mcp/command-tools.ts index cce69527d6..b8d8cba4e5 100644 --- a/src/mcp/command-tools.ts +++ b/src/mcp/command-tools.ts @@ -45,6 +45,7 @@ export type ToolResult = { type CommandToolExecutorDeps = { createClient?: ( config: AgentDeviceClientConfig, + ingress: { recordSource: 'explicit' | 'default' }, ) => AgentDeviceClient | Promise; runCommand?: ( client: AgentDeviceClient, @@ -185,7 +186,11 @@ export function createCommandToolExecutor(deps: CommandToolExecutorDeps = {}): C const config = readMcpToolConfig(resolvedInput); const commandInput = stripMcpConfigFields(resolvedInput); const pinnedInput = refPins.pinInput(name, commandInput, config.client.stateDir); - const client = await createClient(deps, config.client); + const client = await createClient( + deps, + config.client, + input.record === true ? 'explicit' : 'default', + ); try { const result = await (deps.runCommand ?? runCommand)(client, name, pinnedInput); refPins.mergeCommandResult(name, result, config.client.stateDir, commandInput.session); @@ -245,13 +250,15 @@ export const commandToolExecutor = createCommandToolExecutor(); async function createClient( deps: CommandToolExecutorDeps, config: AgentDeviceClientConfig, + recordSource: 'explicit' | 'default', ): Promise { - if (deps.createClient) return await deps.createClient(config); + if (deps.createClient) return await deps.createClient(config, { recordSource }); const { createAgentDeviceClient } = await import('../agent-device-client.ts'); const { createEnsuredDaemonTransport } = await import('../daemon-client/daemon-client.ts'); // One client per tool call, so each call ensures the daemon it is about to use. const ensuring = createEnsuredDaemonTransport(); return createAgentDeviceClient(config, { + recordSource, // The client builds the same request the daemon transport takes; its type only differs in the // session being optional, which `sendToDaemon` defaults. transport: async (req, context) => diff --git a/test/integration/provider-scenarios/replay-repair-record-exclusion.test.ts b/test/integration/provider-scenarios/replay-repair-record-exclusion.test.ts index 6386244ec0..2a4f41f6c3 100644 --- a/test/integration/provider-scenarios/replay-repair-record-exclusion.test.ts +++ b/test/integration/provider-scenarios/replay-repair-record-exclusion.test.ts @@ -134,13 +134,21 @@ async function assertRepairSegmentReadExclusion(params: { }); assertRpcOk(diagnosticRead); + const defaultRecordRead = await daemon.callCommand('get', ['text', SEARCH_SELECTOR], { + ...selection, + record: true, + }); + assertRpcOk(defaultRecordRead); + // (b) The corrective read (the wave-3 E3 shape: the diverged step is itself // a read), forced into the heal with `--record`. - const correctiveRead = await daemon.callCommand('get', ['text', SEARCH_SELECTOR], { + await daemon.client().interactions.get({ + format: 'text', + selector: SEARCH_SELECTOR, ...selection, record: true, }); - assertRpcOk(correctiveRead); + // (c) `--record` and `--no-record` are opposite intents for one action. const conflictingFlags = await daemon.callCommand('get', ['text', SEARCH_SELECTOR], { diff --git a/website/docs/docs/replay-e2e.md b/website/docs/docs/replay-e2e.md index 497177f3dd..1a3ccdc1df 100644 --- a/website/docs/docs/replay-e2e.md +++ b/website/docs/docs/replay-e2e.md @@ -351,6 +351,8 @@ Text output prints a compact summary of the same fields; `--json`/MCP carry the ## Resume a failed replay +During a repair armed with `replay --save-script`, diagnostic reads stay out of the healed script. To keep a corrective read, pass `--record` on that invocation, or `record: true` in that SDK/MCP call. A project-config `record: true` default does not opt individual reads into the heal. Authored replay observations are retained automatically. + `replay --from --plan-digest ` resumes **at** plan step `n`, not after it, skipping `1..n-1` without executing them. Both flags come from a divergence report's `resume` field — `from` is the failed step, `planDigest` is the digest of the exact unchanged plan that produced it. Choose one recovery workflow: From c6803c7d852fea72cffbee0c4360ca9f0f66ba4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 09:52:48 +0200 Subject: [PATCH 09/15] test: isolate Windows home in publication tests Set HOME and USERPROFILE together for CLI and MCP publication setups. Review: https://github.com/callstack/agent-device/pull/3760#discussion_r4301952218 --- src/__tests__/cli-record-publication.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/__tests__/cli-record-publication.test.ts b/src/__tests__/cli-record-publication.test.ts index 52898ba0f8..33b433dcb8 100644 --- a/src/__tests__/cli-record-publication.test.ts +++ b/src/__tests__/cli-record-publication.test.ts @@ -53,7 +53,7 @@ test.each([false, true])( const publication = repairPublication(root); const result = await runCliCapture(['snapshot', '--json', ...(explicit ? ['--record'] : [])], { cwd: project, - env: { HOME: home }, + env: { HOME: home, USERPROFILE: home }, sendToDaemon: publication.transport, }); expect(result.calls).toHaveLength(1); @@ -68,7 +68,7 @@ test.each([false, true])( const { root, home, project } = makeTempWorkspace(); fs.writeFileSync(path.join(project, 'agent-device.json'), JSON.stringify({ record: true })); const publication = repairPublication(root); - const restoreEnv = installIsolatedCliTestEnv({ HOME: home }); + const restoreEnv = installIsolatedCliTestEnv({ HOME: home, USERPROFILE: home }); const previousCwd = process.cwd(); process.chdir(project); try { From 889fc870d4367d0174d46cccaae2766ad1e6d60b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 10:17:32 +0200 Subject: [PATCH 10/15] test(replay): route publication regressions through public daemon --- .../__tests__/session-action-recorder.test.ts | 6 +- src/__tests__/cli-record-publication.test.ts | 95 ------------ .../cli-record-publication.test.ts | 141 ++++++++++++++++++ .../replay-repair-record-exclusion.test.ts | 34 +---- .../provider-scenarios/replay-repair-world.ts | 35 +++++ 5 files changed, 182 insertions(+), 129 deletions(-) delete mode 100644 src/__tests__/cli-record-publication.test.ts create mode 100644 test/integration/provider-scenarios/cli-record-publication.test.ts create mode 100644 test/integration/provider-scenarios/replay-repair-world.ts diff --git a/packages/daemon/src/core/__tests__/session-action-recorder.test.ts b/packages/daemon/src/core/__tests__/session-action-recorder.test.ts index a23fc98059..419fe1ebd4 100644 --- a/packages/daemon/src/core/__tests__/session-action-recorder.test.ts +++ b/packages/daemon/src/core/__tests__/session-action-recorder.test.ts @@ -7,7 +7,11 @@ import { isSessionRecording } from '../session-script-publication-capability.ts' * (`selector-recording.test.ts` covers which commands set `interactiveObservation`). */ import { test, expect } from 'vitest'; -import { isInteractiveObservation, recordActionEntry, type RecordActionEntry } from '../session-action-recorder.ts'; +import { + isInteractiveObservation, + recordActionEntry, + type RecordActionEntry, +} from '../session-action-recorder.ts'; import { authoringPublication, makeIosSession } from '../../__tests__/support/session-factories.ts'; test.each([ diff --git a/src/__tests__/cli-record-publication.test.ts b/src/__tests__/cli-record-publication.test.ts deleted file mode 100644 index 33b433dcb8..0000000000 --- a/src/__tests__/cli-record-publication.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { expect, test } from 'vitest'; -import type { CommandFlags } from '@agent-device/contracts/command'; -import type { AgentDeviceDaemonTransport } from '@agent-device/contracts/client'; -import { parseReplayScriptDetailed } from '@agent-device/ad-script'; -import { SessionStore } from '@agent-device/daemon/testing/session-store'; -import { isInteractiveObservation } from '@agent-device/daemon/testing/session-action-recorder'; -import { makeRepairCompleteSession } from '@agent-device/daemon/testing/__tests__/support/session-factories'; -import { runCliCapture } from './cli-capture.ts'; -import { makeTempWorkspace } from './cli-config-fixtures.ts'; -import { createAgentDeviceClient } from '../agent-device-client.ts'; -import { createCommandToolExecutor } from '../mcp/command-tools.ts'; -import { installIsolatedCliTestEnv } from './cli-test-env.ts'; - -function repairPublication(root: string) { - const store = new SessionStore(path.join(root, 'sessions')); - const session = makeRepairCompleteSession('default', { - actions: [{ ts: 0, command: 'open', positionals: ['Demo'], flags: {} }], - }); - const ref = store.publish(session.name, session); - const transport: AgentDeviceDaemonTransport = async (req) => { - store.recordAction(ref, { - command: req.command, - positionals: req.positionals, - flags: req.flags as CommandFlags, - interactiveObservation: isInteractiveObservation({ - ...req, - token: 'test', - session: 'default', - }), - }); - return { ok: true, data: { nodes: [] } }; - }; - return { - session, - transport, - commands: () => { - const written = store.writeSessionLog(ref); - if (!written.written) throw new Error('expected a healed script'); - return parseReplayScriptDetailed(fs.readFileSync(written.path, 'utf8')).actions.map( - (action) => action.command, - ); - }, - }; -} - -test.each([false, true])( - 'CLI project record default retains a repair snapshot only with explicit opt-in=%s', - async (explicit) => { - const { root, home, project } = makeTempWorkspace(); - fs.writeFileSync(path.join(project, 'agent-device.json'), JSON.stringify({ record: true })); - const publication = repairPublication(root); - const result = await runCliCapture(['snapshot', '--json', ...(explicit ? ['--record'] : [])], { - cwd: project, - env: { HOME: home, USERPROFILE: home }, - sendToDaemon: publication.transport, - }); - expect(result.calls).toHaveLength(1); - expect(result.calls[0]?.flags?.record).toBe(true); - expect(publication.commands()).toEqual(explicit ? ['open', 'snapshot'] : ['open']); - }, -); - -test.each([false, true])( - 'MCP project record default retains a repair snapshot only with explicit opt-in=%s', - async (explicit) => { - const { root, home, project } = makeTempWorkspace(); - fs.writeFileSync(path.join(project, 'agent-device.json'), JSON.stringify({ record: true })); - const publication = repairPublication(root); - const restoreEnv = installIsolatedCliTestEnv({ HOME: home, USERPROFILE: home }); - const previousCwd = process.cwd(); - process.chdir(project); - try { - const executor = createCommandToolExecutor({ - createClient: (config, ingress) => - createAgentDeviceClient(config, { ...ingress, transport: publication.transport }), - }); - const result = await executor.execute('snapshot', explicit ? { record: true } : {}); - expect(result.isError).toBe(false); - expect(publication.commands()).toEqual(explicit ? ['open', 'snapshot'] : ['open']); - } finally { - process.chdir(previousCwd); - restoreEnv(); - } - }, -); - -test('SDK per-call record explicitly retains a corrective snapshot', async () => { - const { root } = makeTempWorkspace(); - const publication = repairPublication(root); - const client = createAgentDeviceClient({}, { transport: publication.transport }); - await client.capture.snapshot({ record: true }); - expect(publication.commands()).toEqual(['open', 'snapshot']); -}); diff --git a/test/integration/provider-scenarios/cli-record-publication.test.ts b/test/integration/provider-scenarios/cli-record-publication.test.ts new file mode 100644 index 0000000000..89b846f128 --- /dev/null +++ b/test/integration/provider-scenarios/cli-record-publication.test.ts @@ -0,0 +1,141 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { expect, test, onTestFinished } from 'vitest'; +import type { AgentDeviceDaemonTransport } from '@agent-device/contracts/client'; +import { parseReplayScriptDetailed } from '@agent-device/ad-script'; +import { createProviderScenarioHarness } from './harness.ts'; +import { PROVIDER_SCENARIO_ANDROID } from './fixtures.ts'; +import { ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT } from '../../../src/__tests__/test-utils/android-snapshot-helper.ts'; +import { + createAndroidProviderShellState, + updateAndroidProviderImeShellState, +} from './android-ime-lifecycle-world.ts'; +import { androidRepairAdbResult } from './replay-repair-world.ts'; +import { runCliCapture } from '../../../src/__tests__/cli-capture.ts'; +import { makeTempWorkspace } from '../../../src/__tests__/cli-config-fixtures.ts'; +import { createAgentDeviceClient } from '../../../src/agent-device-client.ts'; +import { createCommandToolExecutor } from '../../../src/mcp/command-tools.ts'; +import { installIsolatedCliTestEnv } from '../../../src/__tests__/cli-test-env.ts'; + +async function repairPublication(root: string) { + const shellState = createAndroidProviderShellState(); + const daemon = await createProviderScenarioHarness({ + androidAdbProvider: () => ({ + snapshotHelperArtifact: ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT, + exec: async (args) => { + updateAndroidProviderImeShellState(args, shellState); + return androidRepairAdbResult(args, shellState); + }, + }), + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }); + const selection = { platform: 'android' as const, serial: PROVIDER_SCENARIO_ANDROID.id }; + const client = daemon.client(); + try { + await client.apps.open({ app: 'settings', ...selection }); + const repairPath = path.join(root, 'repair.ad'); + fs.writeFileSync( + repairPath, + 'snapshot -i\npress label="NoSuchControl"\nis visible id=com.android.settings:id/search\n', + ); + const divergence = await client.replay + .run({ path: repairPath, saveScript: true, ...selection }) + .then( + () => { + throw new Error('expected repair divergence'); + }, + (error: { + details: { + divergence: { + resume: { from: number; planDigest: string; repairSessionHeld: boolean }; + }; + }; + }) => error.details.divergence.resume, + ); + expect(divergence.repairSessionHeld).toBe(true); + const transport: AgentDeviceDaemonTransport = async (req) => { + const result = await daemon.callCommand(req.command, req.positionals, { + ...req.flags, + ...selection, + }); + return result.json.error + ? { ok: false, error: result.json.error.data } + : { ok: true, data: result.json.result.data }; + }; + return { + transport, + close: () => daemon.close(), + commands: async () => { + await client.replay.run({ + path: repairPath, + resumeFrom: divergence.from + 1, + resumePlanDigest: divergence.planDigest, + ...selection, + }); + await client.apps.close({ saveScript: true }); + return parseReplayScriptDetailed( + fs.readFileSync(path.join(root, 'repair.healed.ad'), 'utf8'), + ).actions.map((action) => action.command); + }, + }; + } catch (error) { + await daemon.close(); + throw error; + } +} + +test.each([false, true])( + 'CLI project record default retains a repair snapshot only with explicit opt-in=%s', + async (explicit) => { + const { root, home, project } = makeTempWorkspace(); + fs.writeFileSync(path.join(project, 'agent-device.json'), JSON.stringify({ record: true })); + const publication = await repairPublication(root); + onTestFinished(() => publication.close()); + const result = await runCliCapture(['snapshot', '--json', ...(explicit ? ['--record'] : [])], { + cwd: project, + env: { HOME: home, USERPROFILE: home }, + sendToDaemon: publication.transport, + }); + expect(result.calls).toHaveLength(1); + expect(result.calls[0]?.flags?.record).toBe(true); + expect(await publication.commands()).toEqual( + explicit ? ['snapshot', 'snapshot', 'is', 'close'] : ['snapshot', 'is', 'close'], + ); + }, +); + +test.each([false, true])( + 'MCP project record default retains a repair snapshot only with explicit opt-in=%s', + async (explicit) => { + const { root, home, project } = makeTempWorkspace(); + fs.writeFileSync(path.join(project, 'agent-device.json'), JSON.stringify({ record: true })); + const publication = await repairPublication(root); + onTestFinished(() => publication.close()); + const restoreEnv = installIsolatedCliTestEnv({ HOME: home, USERPROFILE: home }); + const previousCwd = process.cwd(); + process.chdir(project); + try { + const executor = createCommandToolExecutor({ + createClient: (config, ingress) => + createAgentDeviceClient(config, { ...ingress, transport: publication.transport }), + }); + const result = await executor.execute('snapshot', explicit ? { record: true } : {}); + expect(result.isError).toBe(false); + expect(await publication.commands()).toEqual( + explicit ? ['snapshot', 'snapshot', 'is', 'close'] : ['snapshot', 'is', 'close'], + ); + } finally { + process.chdir(previousCwd); + restoreEnv(); + } + }, +); + +test('SDK per-call record explicitly retains a corrective snapshot', async () => { + const { root } = makeTempWorkspace(); + const publication = await repairPublication(root); + onTestFinished(() => publication.close()); + const client = createAgentDeviceClient({}, { transport: publication.transport }); + await client.capture.snapshot({ record: true }); + expect(await publication.commands()).toEqual(['snapshot', 'snapshot', 'is', 'close']); +}); diff --git a/test/integration/provider-scenarios/replay-repair-record-exclusion.test.ts b/test/integration/provider-scenarios/replay-repair-record-exclusion.test.ts index 2a4f41f6c3..e1a1669344 100644 --- a/test/integration/provider-scenarios/replay-repair-record-exclusion.test.ts +++ b/test/integration/provider-scenarios/replay-repair-record-exclusion.test.ts @@ -17,9 +17,8 @@ import { test } from 'vitest'; import { ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT } from '../../../src/__tests__/test-utils/android-snapshot-helper.ts'; import type { AndroidAdbProvider } from '@agent-device/platform-android/mechanics'; import { assertRpcError, assertRpcOk } from './assertions.ts'; -import { androidSettingsXml, androidSnapshotHelperOutput } from './android-world.ts'; +import { androidRepairAdbResult } from './replay-repair-world.ts'; import { - androidImeLifecycleAdbResult, createAndroidProviderShellState, updateAndroidProviderImeShellState, } from './android-ime-lifecycle-world.ts'; @@ -149,7 +148,6 @@ async function assertRepairSegmentReadExclusion(params: { record: true, }); - // (c) `--record` and `--no-record` are opposite intents for one action. const conflictingFlags = await daemon.callCommand('get', ['text', SEARCH_SELECTOR], { ...selection, @@ -191,33 +189,3 @@ function healedLines(script: string, command: string): string[] { .map((line) => line.trim()) .filter((line) => line === command || line.startsWith(`${command} `)); } - -function androidRepairAdbResult( - args: readonly string[], - shellState: ReturnType, -): { - stdout: string; - stderr: string; - exitCode: number; -} { - const ime = androidImeLifecycleAdbResult(args.join(' '), args, shellState); - if (ime) return ime; - if (args.join(' ') === 'shell getprop sys.boot_completed') { - return { stdout: '1\n', stderr: '', exitCode: 0 }; - } - if (args.join(' ') === 'shell dumpsys window windows') { - return { - stdout: 'mCurrentFocus=Window{42 u0 com.android.settings/.Settings}\n', - stderr: '', - exitCode: 0, - }; - } - if (args.join(' ').startsWith('shell am instrument ')) { - return { - stdout: androidSnapshotHelperOutput(androidSettingsXml('Display')), - stderr: '', - exitCode: 0, - }; - } - return { stdout: '', stderr: '', exitCode: 0 }; -} diff --git a/test/integration/provider-scenarios/replay-repair-world.ts b/test/integration/provider-scenarios/replay-repair-world.ts new file mode 100644 index 0000000000..9bbd129919 --- /dev/null +++ b/test/integration/provider-scenarios/replay-repair-world.ts @@ -0,0 +1,35 @@ +import { androidSettingsXml, androidSnapshotHelperOutput } from './android-world.ts'; +import { + androidImeLifecycleAdbResult, + createAndroidProviderShellState, +} from './android-ime-lifecycle-world.ts'; + +export function androidRepairAdbResult( + args: readonly string[], + shellState: ReturnType, +): { + stdout: string; + stderr: string; + exitCode: number; +} { + const ime = androidImeLifecycleAdbResult(args.join(' '), args, shellState); + if (ime) return ime; + if (args.join(' ') === 'shell getprop sys.boot_completed') { + return { stdout: '1\n', stderr: '', exitCode: 0 }; + } + if (args.join(' ') === 'shell dumpsys window windows') { + return { + stdout: 'mCurrentFocus=Window{42 u0 com.android.settings/.Settings}\n', + stderr: '', + exitCode: 0, + }; + } + if (args.join(' ').startsWith('shell am instrument ')) { + return { + stdout: androidSnapshotHelperOutput(androidSettingsXml('Display')), + stderr: '', + exitCode: 0, + }; + } + return { stdout: '', stderr: '', exitCode: 0 }; +} From 3f534b81f55a88ddf8ece7d106f6f0ca3d7aec2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 10:50:14 +0200 Subject: [PATCH 11/15] test(replay): cover publication through ensured daemon clients --- .../cli-record-publication.test.ts | 81 ++++++++++++------- .../integration/provider-scenarios/harness.ts | 2 + 2 files changed, 53 insertions(+), 30 deletions(-) diff --git a/test/integration/provider-scenarios/cli-record-publication.test.ts b/test/integration/provider-scenarios/cli-record-publication.test.ts index 89b846f128..ff29a4cfec 100644 --- a/test/integration/provider-scenarios/cli-record-publication.test.ts +++ b/test/integration/provider-scenarios/cli-record-publication.test.ts @@ -1,7 +1,8 @@ import fs from 'node:fs'; import path from 'node:path'; -import { expect, test, onTestFinished } from 'vitest'; -import type { AgentDeviceDaemonTransport } from '@agent-device/contracts/client'; +import { expect, test, onTestFinished, vi } from 'vitest'; +import * as codeSignature from '@agent-device/host-kit/code-signature'; +import { resolveLocalDaemonCodeIdentity } from '../../../src/daemon-client/daemon-launch-spec.ts'; import { parseReplayScriptDetailed } from '@agent-device/ad-script'; import { createProviderScenarioHarness } from './harness.ts'; import { PROVIDER_SCENARIO_ANDROID } from './fixtures.ts'; @@ -18,6 +19,13 @@ import { createCommandToolExecutor } from '../../../src/mcp/command-tools.ts'; import { installIsolatedCliTestEnv } from '../../../src/__tests__/cli-test-env.ts'; async function repairPublication(root: string) { + // The runtime runs inside Vitest, so publish the daemon entry's identity instead of Vitest's. + const identity = await resolveLocalDaemonCodeIdentity(); + if (identity.origin !== 'checkout') throw new Error('expected checkout identity'); + const signature = vi + .spyOn(codeSignature, 'resolveDaemonCodeSignature') + .mockReturnValue(identity.codeSignature); + onTestFinished(() => signature.mockRestore()); const shellState = createAndroidProviderShellState(); const daemon = await createProviderScenarioHarness({ androidAdbProvider: () => ({ @@ -53,17 +61,8 @@ async function repairPublication(root: string) { }) => error.details.divergence.resume, ); expect(divergence.repairSessionHeld).toBe(true); - const transport: AgentDeviceDaemonTransport = async (req) => { - const result = await daemon.callCommand(req.command, req.positionals, { - ...req.flags, - ...selection, - }); - return result.json.error - ? { ok: false, error: result.json.error.data } - : { ok: true, data: result.json.result.data }; - }; return { - transport, + config: { stateDir: daemon.stateDir, daemonTransport: 'http' as const, ...selection }, close: () => daemon.close(), commands: async () => { await client.replay.run({ @@ -88,17 +87,33 @@ test.each([false, true])( 'CLI project record default retains a repair snapshot only with explicit opt-in=%s', async (explicit) => { const { root, home, project } = makeTempWorkspace(); - fs.writeFileSync(path.join(project, 'agent-device.json'), JSON.stringify({ record: true })); const publication = await repairPublication(root); + fs.writeFileSync(path.join(project, 'agent-device.json'), JSON.stringify({ record: true })); onTestFinished(() => publication.close()); - const result = await runCliCapture(['snapshot', '--json', ...(explicit ? ['--record'] : [])], { - cwd: project, - env: { HOME: home, USERPROFILE: home }, - sendToDaemon: publication.transport, - }); - expect(result.calls).toHaveLength(1); - expect(result.calls[0]?.flags?.record).toBe(true); - expect(await publication.commands()).toEqual( + const result = await runCliCapture( + [ + 'snapshot', + '--json', + '--session', + 'default', + '--state-dir', + publication.config.stateDir, + '--daemon-transport', + 'http', + '--platform', + 'android', + '--serial', + publication.config.serial, + ...(explicit ? ['--record'] : []), + ], + { + cwd: project, + env: { HOME: home, USERPROFILE: home }, + useRealDaemonClient: true, + }, + ); + expect(result.code, result.stderr || result.stdout).not.toBe(1); + expect(await publication.commands(), JSON.stringify(result)).toEqual( explicit ? ['snapshot', 'snapshot', 'is', 'close'] : ['snapshot', 'is', 'close'], ); }, @@ -108,20 +123,26 @@ test.each([false, true])( 'MCP project record default retains a repair snapshot only with explicit opt-in=%s', async (explicit) => { const { root, home, project } = makeTempWorkspace(); - fs.writeFileSync(path.join(project, 'agent-device.json'), JSON.stringify({ record: true })); const publication = await repairPublication(root); + fs.writeFileSync(path.join(project, 'agent-device.json'), JSON.stringify({ record: true })); onTestFinished(() => publication.close()); - const restoreEnv = installIsolatedCliTestEnv({ HOME: home, USERPROFILE: home }); + const restoreEnv = installIsolatedCliTestEnv({ + HOME: home, + USERPROFILE: home, + AGENT_DEVICE_STATE_DIR: publication.config.stateDir, + AGENT_DEVICE_DAEMON_TRANSPORT: 'http', + }); const previousCwd = process.cwd(); process.chdir(project); try { - const executor = createCommandToolExecutor({ - createClient: (config, ingress) => - createAgentDeviceClient(config, { ...ingress, transport: publication.transport }), + const executor = createCommandToolExecutor(); + const result = await executor.execute('snapshot', { + platform: 'android', + serial: publication.config.serial, + ...(explicit ? { record: true } : {}), }); - const result = await executor.execute('snapshot', explicit ? { record: true } : {}); - expect(result.isError).toBe(false); - expect(await publication.commands()).toEqual( + expect(result.isError, JSON.stringify(result)).toBe(false); + expect(await publication.commands(), JSON.stringify(result)).toEqual( explicit ? ['snapshot', 'snapshot', 'is', 'close'] : ['snapshot', 'is', 'close'], ); } finally { @@ -135,7 +156,7 @@ test('SDK per-call record explicitly retains a corrective snapshot', async () => const { root } = makeTempWorkspace(); const publication = await repairPublication(root); onTestFinished(() => publication.close()); - const client = createAgentDeviceClient({}, { transport: publication.transport }); + const client = createAgentDeviceClient(publication.config); await client.capture.snapshot({ record: true }); expect(await publication.commands()).toEqual(['snapshot', 'snapshot', 'is', 'close']); }); diff --git a/test/integration/provider-scenarios/harness.ts b/test/integration/provider-scenarios/harness.ts index fd26d9a11a..13f7036f7d 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -70,6 +70,7 @@ export type ProviderScenarioHarness = { ) => Promise; client: () => AgentDeviceClient; hasSession: (session?: string) => Promise; + stateDir: string; baseUrl: string; httpPort: number; token: string; @@ -268,6 +269,7 @@ export async function createProviderScenarioHarness( client: () => client, hasSession: async (name = 'default') => (await client.sessions.list()).some((session) => session.name === name), + stateDir, baseUrl, httpPort: runtime.httpPort, token: runtime.token, From 879c810895da8f8dff0752fd5db0bced56e4530a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 15:55:08 +0200 Subject: [PATCH 12/15] fix(cli): retire the record config key with a migration message The config key "record" and AGENT_DEVICE_RECORD were released (v0.21.10) as command defaults, but the repair-segment recording opt-in they feed needs per-invocation provenance a config default cannot carry. Retire them at the config owner: a config file or the environment still naming the key fails with guidance to pass --record per invocation (or record: true in an SDK/MCP call), instead of silently doing nothing. --record stays a per-invocation flag; the declaration drops projectConfig. --- CHANGELOG.md | 2 +- .../src/flag-definitions-action.ts | 4 +- src/__tests__/cli-config.test.ts | 55 +++++++++++++++++++ src/commands/schema/cli-config.ts | 29 ++++++++++ website/docs/docs/configuration.md | 1 + website/docs/docs/replay-e2e.md | 2 +- 6 files changed, 89 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2437695c0..4f2e4491fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ - Behavior change: the CLI and MCP now call `ensureDaemon()` explicitly once their config is resolved, then attach to the daemon it leaves. A command that finds a daemon from another version or build replaces it only if the daemon reports no request, session, repair session or live lease. A resumable upload still streaming and a host human-control hold that names no lease are not counted, so a replacement can end them. A daemon that answers nothing on any transport is retired after repeated failed liveness probes, which cannot show it idle. A busy one fails the command with `details.reason: 'daemon-busy'` instead of being replaced with its sessions, and a daemon from a release that predates quiescing fails it with `'daemon-incompatible'`; stop either with `agent-device daemon stop --state-dir `. The takeover notice moves from `Replacing daemon … : ` to `Replaced idle daemon …` (an incompatible daemon) or `Replaced unreachable daemon …` (one that answered nothing). A request that times out no longer retires an unresponsive daemon from the CLI or MCP; the error names `agent-device daemon stop --state-dir `. A one-shot `replay` or `test` with an explicit `--state-dir` leaves the daemon it started running until its idle reap instead of stopping it. Remote `--daemon-base-url` connections and replay on its private ephemeral daemon are unchanged. Node.js clients keep `daemonLifecycle: 'managed'` until 0.22. - Add `ensureDaemon({ stateDir })` and the client config `daemonLifecycle: 'attach' | 'managed'`. An `attach` client connects to the daemon registered in the state directory and never starts, replaces or kills one, including after a request times out. It fails with `details.reason: 'daemon-not-running'` (the hint points at `ensureDaemon()`) or `'daemon-incompatible'` (both identities in `details`, the hint names `agent-device daemon stop`). `ensureDaemon()` starts a missing daemon and replaces an incompatible one that answers only through an authenticated quiesce request the daemon answers itself (a resumable upload still streaming and a human-control hold that names no lease are not counted, so a replacement can end them): it refuses with `daemon-busy` while any request is in flight or any session, repair session or live lease exists, and a daemon from a release before this one is never replaced. A registered daemon that answers nothing is retired, as is one that times out on the quiesce request and then fails repeated liveness probes; neither can be asked. A remote `daemonBaseUrl` connection never starts or retires a local daemon in either mode. `managed` stays the default and behaves as before until 0.22. - `lease_allocate` (and `client.leases.allocate()`) with a `leaseProvider` that no registered runtime claims now refuses before recording a lease, with `UNSUPPORTED_OPERATION`, `error.details.reason: provider-not-registered`, `details.provider`, `details.registeredProviders`, and `details.failedPlugins` naming a plugin that failed to register. Previously a plugin provider that the daemon never loaded passed admission and recorded a lease with no device behind it. Leases with no provider (a `leaseBackend` alone included), `proxy` and `cloud` need no runtime and still allocate. Restart the daemon with the provider configured, then retry. -- Healed scripts retain authored snapshot steps and snapshots explicitly included with `--record` during repair. Diagnostic snapshots remain excluded, including when project config sets `record: true`. +- Healed scripts retain authored snapshot steps and snapshots explicitly included with `--record` during repair. Diagnostic snapshots remain excluded. Behavior change: the config key `record` and `AGENT_DEVICE_RECORD` are retired — `--record` is per-invocation only, and a config file or environment naming the key fails with migration guidance naming `--record` and the SDK/MCP `record: true` call option. - Requests that share a session or device now queue until the preceding request finishes disposing its runtime binding and releasing its device claim, including after failure or cancellation. - Add `uninstall ` and `client.apps.uninstall({ app })` to remove an installed app on adb-backed Android targets, iOS simulators, and CoreDevice iOS devices. Both report `wasInstalled`. A package name or bundle id that is not installed succeeds with `wasInstalled: false`. Other targets, Appium and Limrun sessions included, refuse it before anything runs. - Add `settings mobile-data on|off` for adb-backed Android targets. It switches mobile data through `svc data` and reports the `mobileData` state stored in the global `mobile_data` row; multi-SIM read-back is not covered yet. Every other target refuses it before anything runs, and `--app` is refused because the switch is device-wide. diff --git a/packages/command-registry/src/flag-definitions-action.ts b/packages/command-registry/src/flag-definitions-action.ts index 0fcea104dd..0a760234ee 100644 --- a/packages/command-registry/src/flag-definitions-action.ts +++ b/packages/command-registry/src/flag-definitions-action.ts @@ -432,8 +432,8 @@ export const ACTION_FLAG_DEFINITIONS: readonly FlagDefinition[] = [ // the synopsis stays the invocation they are choosing between (#1271 stage 2). usageHidden: true, usageDescription: - 'Force-record this action into a repair-armed heal for this invocation; a project-config default does not opt in (mutually exclusive with --no-record)', - projectConfig: true, + 'Force-record this action into a repair-armed heal; per-invocation only, the retired config key "record" no longer opts in (mutually exclusive with --no-record)', + projectConfig: false, recorded: true, }, ]; diff --git a/src/__tests__/cli-config.test.ts b/src/__tests__/cli-config.test.ts index ea362cfe14..175d3606c2 100644 --- a/src/__tests__/cli-config.test.ts +++ b/src/__tests__/cli-config.test.ts @@ -118,6 +118,61 @@ test('retired screenshotMaxSize config key is refused with migration guidance', fs.rmSync(root, { recursive: true, force: true }); }); +test('retired record config key is refused with migration guidance on every config surface', async () => { + const { root, home, project } = makeTempWorkspace(); + fs.mkdirSync(path.join(home, '.agent-device'), { recursive: true }); + fs.writeFileSync(path.join(project, 'agent-device.json'), JSON.stringify({ record: true })); + fs.writeFileSync(path.join(home, '.agent-device', 'config.json'), JSON.stringify({})); + + const result = await runCliCapture(['snapshot'], { + cwd: project, + env: { HOME: home }, + defaultResponse: { ok: true, data: {} }, + }); + assert.equal(result.code, 1); + assert.match(result.stderr, /"record" in project config file .* was removed/); + assert.match(result.stderr, /--record is per-invocation only/); + + fs.rmSync(path.join(project, 'agent-device.json')); + fs.writeFileSync( + path.join(home, '.agent-device', 'config.json'), + JSON.stringify({ record: true }), + ); + const fromUserConfig = await runCliCapture(['snapshot'], { + cwd: project, + env: { HOME: home }, + defaultResponse: { ok: true, data: {} }, + }); + assert.equal(fromUserConfig.code, 1); + assert.match(fromUserConfig.stderr, /"record" in config file .* was removed/); + + fs.writeFileSync(path.join(home, '.agent-device', 'config.json'), JSON.stringify({})); + fs.rmSync(root, { recursive: true, force: true }); +}); + +test('retired AGENT_DEVICE_RECORD fails closed only for commands that accept --record', async () => { + const { root, home, project } = makeTempWorkspace(); + const env = { HOME: home, AGENT_DEVICE_RECORD: 'true' }; + + const snapshot = await runCliCapture(['snapshot'], { + cwd: project, + env, + defaultResponse: { ok: true, data: {} }, + }); + assert.equal(snapshot.code, 1); + assert.match(snapshot.stderr, /AGENT_DEVICE_RECORD was removed/); + assert.match(snapshot.stderr, /--record is per-invocation only/); + + const unaffected = await runCliCapture(['devices', '--json'], { + cwd: project, + env, + defaultResponse: { ok: true, data: { devices: [] } }, + }); + assert.equal(unaffected.code, null); + + fs.rmSync(root, { recursive: true, force: true }); +}); + test('config can set appsFilter through canonical enum values', async () => { const { root, home, project } = makeTempWorkspace(); fs.mkdirSync(path.join(home, '.agent-device'), { recursive: true }); diff --git a/src/commands/schema/cli-config.ts b/src/commands/schema/cli-config.ts index 2db6ad4876..1e3aadb96e 100644 --- a/src/commands/schema/cli-config.ts +++ b/src/commands/schema/cli-config.ts @@ -106,6 +106,18 @@ function loadSingleConfigFile(entry: ConfigPath): Partial { }); } +// The `record` config key was released (v0.21.10) as a project/user-config default, but +// the repair-segment exclusion it feeds needs per-invocation provenance a config default +// cannot carry. The key is retired: `--record` stays a per-invocation flag, and a config +// file or environment still naming the key fails with this migration guidance rather +// than silently doing nothing. +const RETIRED_RECORD_CONFIG_KEY = { + flagKey: 'record', + envVar: 'AGENT_DEVICE_RECORD', + migration: + '--record is per-invocation only: pass --record on the command, or record: true in the SDK/MCP call, to keep a corrective read in a healed script', +} as const; + function parseConfigObject( source: Record, origin: { source: ConfigFileSource; label: string }, @@ -115,6 +127,12 @@ function parseConfigObject( ([key]) => key !== 'plugins' || origin.source !== 'user', ); for (const [rawKey, rawValue] of entries) { + if (rawKey === RETIRED_RECORD_CONFIG_KEY.flagKey) { + throw new AppError( + 'INVALID_ARGS', + `Config key "${rawKey}" in ${origin.label} was removed; ${RETIRED_RECORD_CONFIG_KEY.migration}.`, + ); + } const key = rawKey as FlagKey; const spec = getOptionSpec(key); if (!spec) { @@ -158,6 +176,17 @@ function parseConfigObject( const RETIRED_MAX_SIZE_ENV_COMMANDS = new Set(['screenshot', 'record']); function readEnvFlagDefaults(env: EnvMap, command: string | null): Partial { + if ( + command !== null && + getOptionSpec(RETIRED_RECORD_CONFIG_KEY.flagKey)?.supportsCommand(command) === true && + typeof env[RETIRED_RECORD_CONFIG_KEY.envVar] === 'string' && + env[RETIRED_RECORD_CONFIG_KEY.envVar]!.trim().length > 0 + ) { + throw new AppError( + 'INVALID_ARGS', + `${RETIRED_RECORD_CONFIG_KEY.envVar} was removed; ${RETIRED_RECORD_CONFIG_KEY.migration}.`, + ); + } const retiredEnvValue = env[RETIRED_SCREENSHOT_MAX_SIZE.envVar]; if ( command !== null && diff --git a/website/docs/docs/configuration.md b/website/docs/docs/configuration.md index f72b6af2ae..eb2896d7b0 100644 --- a/website/docs/docs/configuration.md +++ b/website/docs/docs/configuration.md @@ -96,6 +96,7 @@ explicit config: - local code and write destinations (`reporter`, `reportJunit`, `saveScript`, `launchConsole`) Project config can also set `snapshotDepth`, `snapshotScope`, `screenshotScale`, `activity`, `relaunch`, `shutdown`, `fps`, and `quality`. Local paths and executable modules, such as `stepsFile` and `reporter`, are allowed only in user or explicit config. +The `record` config key and `AGENT_DEVICE_RECORD` were removed. `--record` is per-invocation only: pass it on the command, or set `record: true` in an SDK/MCP call, to keep a corrective read in a healed script. `install-from-source` can read a GitHub Actions artifact source from user or explicit config when the remote daemon supports resolving CI artifacts. Repository config rejects this key: diff --git a/website/docs/docs/replay-e2e.md b/website/docs/docs/replay-e2e.md index 1a3ccdc1df..120cf72652 100644 --- a/website/docs/docs/replay-e2e.md +++ b/website/docs/docs/replay-e2e.md @@ -351,7 +351,7 @@ Text output prints a compact summary of the same fields; `--json`/MCP carry the ## Resume a failed replay -During a repair armed with `replay --save-script`, diagnostic reads stay out of the healed script. To keep a corrective read, pass `--record` on that invocation, or `record: true` in that SDK/MCP call. A project-config `record: true` default does not opt individual reads into the heal. Authored replay observations are retained automatically. +During a repair armed with `replay --save-script`, diagnostic reads stay out of the healed script. To keep a corrective read, pass `--record` on that invocation, or `record: true` in that SDK/MCP call. `--record` is per-invocation only: the config key `record` and `AGENT_DEVICE_RECORD` were removed, and a config file or environment still naming them fails with migration guidance. Authored replay observations are retained automatically. `replay --from --plan-digest ` resumes **at** plan step `n`, not after it, skipping `1..n-1` without executing them. Both flags come from a divergence report's `resume` field — `from` is the failed step, `planDigest` is the digest of the exact unchanged plan that produced it. From c77252c6a9025f985a01bf7296e341d04d94acdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 15:55:13 +0200 Subject: [PATCH 13/15] refactor(cli): key repair recording off flags.record and drop the provenance wire field With the config and environment sources retired, flags.record on the wire is always per-call intent, so the recorder reads it again and the recordingIntent field and recordSource plumbing through the CLI, MCP, and client are removed. --- packages/contracts/src/command-flags.ts | 2 - .../core/__tests__/selector-recording.test.ts | 21 +--- .../__tests__/session-action-recorder.test.ts | 43 ++++---- .../__tests__/session-script-writer.test.ts | 2 - .../src/core/session-action-recorder.ts | 4 +- ...ion-replay-repair-record-exclusion.test.ts | 2 +- src/agent-device-client.ts | 4 +- src/cli.ts | 1 - src/commands/command-flags.ts | 4 - src/mcp/command-tools.ts | 11 +-- .../cli-record-publication.test.ts | 99 ++++++++++++++++--- .../replay-repair-record-exclusion.test.ts | 6 -- 12 files changed, 114 insertions(+), 85 deletions(-) diff --git a/packages/contracts/src/command-flags.ts b/packages/contracts/src/command-flags.ts index 57f6b84ec3..7b56cc0e6d 100644 --- a/packages/contracts/src/command-flags.ts +++ b/packages/contracts/src/command-flags.ts @@ -26,8 +26,6 @@ export type MaestroRuntimeFlags = { }; export type CommandFlags = Omit & { - /** Per-action recording opt-in established before project defaults lose their provenance. */ - recordingIntent?: 'explicit-record'; batchSteps?: DaemonBatchStep[]; clearAppState?: boolean; launchArgs?: string[]; diff --git a/packages/daemon/src/core/__tests__/selector-recording.test.ts b/packages/daemon/src/core/__tests__/selector-recording.test.ts index 81ec80c403..f82fbf976e 100644 --- a/packages/daemon/src/core/__tests__/selector-recording.test.ts +++ b/packages/daemon/src/core/__tests__/selector-recording.test.ts @@ -92,24 +92,9 @@ test('--record forces get/is/find through even while repair-armed', () => { }); store.publish('default', session); - recordIfSession( - store, - store.lookup('default'), - req('get', { record: true, recordingIntent: 'explicit-record' }), - {}, - ); - recordIfSession( - store, - store.lookup('default'), - req('is', { record: true, recordingIntent: 'explicit-record' }), - {}, - ); - recordIfSession( - store, - store.lookup('default'), - req('find', { record: true, recordingIntent: 'explicit-record' }), - {}, - ); + recordIfSession(store, store.lookup('default'), req('get', { record: true }), {}); + recordIfSession(store, store.lookup('default'), req('is', { record: true }), {}); + recordIfSession(store, store.lookup('default'), req('find', { record: true }), {}); expect(store.get('default')!.actions.map((a) => a.command)).toEqual(['get', 'is', 'find']); }); diff --git a/packages/daemon/src/core/__tests__/session-action-recorder.test.ts b/packages/daemon/src/core/__tests__/session-action-recorder.test.ts index 419fe1ebd4..94d2c33467 100644 --- a/packages/daemon/src/core/__tests__/session-action-recorder.test.ts +++ b/packages/daemon/src/core/__tests__/session-action-recorder.test.ts @@ -42,28 +42,25 @@ test.each([ }, ); -test.each([{}, { record: true }])( - 'an observation is excluded without explicit intent, flags=%j', - (flags) => { - const session = makeIosSession('default', { - scriptPublication: { - kind: 'repair', - status: 'armed', - target: { kind: 'default', force: false }, - boundary: 0, - }, - }); - const action = recordActionEntry(session, { - command: 'get', - positionals: ['attrs', 'id="save"'], - flags, - result: {}, - interactiveObservation: true, - }); - expect(action).toBeUndefined(); - expect(session.actions).toHaveLength(0); - }, -); +test('an observation without --record is excluded while repair-armed', () => { + const session = makeIosSession('default', { + scriptPublication: { + kind: 'repair', + status: 'armed', + target: { kind: 'default', force: false }, + boundary: 0, + }, + }); + const action = recordActionEntry(session, { + command: 'get', + positionals: ['attrs', 'id="save"'], + flags: {}, + result: {}, + interactiveObservation: true, + }); + expect(action).toBeUndefined(); + expect(session.actions).toHaveLength(0); +}); test('--record forces an observation-only action through while repair-armed', () => { const session = makeIosSession('default', { @@ -77,7 +74,7 @@ test('--record forces an observation-only action through while repair-armed', () const action = recordActionEntry(session, { command: 'get', positionals: ['attrs', 'id="save"'], - flags: { record: true, recordingIntent: 'explicit-record' }, + flags: { record: true }, result: {}, interactiveObservation: true, }); diff --git a/packages/daemon/src/core/__tests__/session-script-writer.test.ts b/packages/daemon/src/core/__tests__/session-script-writer.test.ts index d8ec855fd3..fe63725e46 100644 --- a/packages/daemon/src/core/__tests__/session-script-writer.test.ts +++ b/packages/daemon/src/core/__tests__/session-script-writer.test.ts @@ -134,7 +134,6 @@ test.each(['authored', 'explicit-record'] as const)( intent === 'explicit-record' ? { record: true, - recordingIntent: 'explicit-record' as const, snapshotInteractiveOnly: true, } : { snapshotInteractiveOnly: true }, @@ -886,7 +885,6 @@ test.each([ positionals: [], flags: { record, - recordingIntent: record ? 'explicit-record' : undefined, snapshotInteractiveOnly: true, }, interactiveObservation: !replayPlanStep, diff --git a/packages/daemon/src/core/session-action-recorder.ts b/packages/daemon/src/core/session-action-recorder.ts index 442bd92494..7f832ad7c3 100644 --- a/packages/daemon/src/core/session-action-recorder.ts +++ b/packages/daemon/src/core/session-action-recorder.ts @@ -89,7 +89,7 @@ export function recordActionEntry( ...(OBSERVATION_ONLY_COMMANDS.has(recordedEntry.command) ? recordedEntry.interactiveObservation === false ? { publicationIntent: 'authored' as const } - : recordedEntry.flags.recordingIntent === 'explicit-record' + : recordedEntry.flags.record === true ? { publicationIntent: 'explicit-record' as const } : {} : {}), @@ -367,7 +367,7 @@ function isExcludedRepairSegmentObservation( ): boolean { if (!entry.interactiveObservation) return false; if (repairSessionBoundary(session) === undefined) return false; - return entry.flags.recordingIntent !== 'explicit-record'; + return entry.flags.record !== true; } // The keys a recorded action carries derive from each flag declaration's `recorded` 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 d632e19c0c..8c44f5e52e 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 @@ -301,7 +301,7 @@ test.each(['get', 'snapshot'])( ctx.sessionStore.recordAction(storeSessionForTest(ctx.sessionStore, session), { command, positionals: command === 'get' ? ['attrs', '@e7'] : [], - flags: { record: true, recordingIntent: 'explicit-record' }, + flags: { record: true }, result: { selectorChain: ['id="save-v2"'] }, interactiveObservation: true, }); diff --git a/src/agent-device-client.ts b/src/agent-device-client.ts index 318dad7d3d..4bc838f0a0 100644 --- a/src/agent-device-client.ts +++ b/src/agent-device-client.ts @@ -82,7 +82,7 @@ import { createRequestGuard } from './daemon-client/daemon-client-transport.ts'; export function createAgentDeviceClient( config: AgentDeviceClientConfig = {}, - deps: { transport?: AgentDeviceDaemonTransport; recordSource?: 'explicit' | 'default' } = {}, + deps: { transport?: AgentDeviceDaemonTransport } = {}, ): AgentDeviceClient { const transport = deps.transport ?? sendToDaemon; // A typo in a JSON or JS config must not fall through to the default and manage a daemon. @@ -113,7 +113,7 @@ export function createAgentDeviceClient( command, positionals, ...(input ? { input } : {}), - flags: buildRequestFlags(merged, metadataFlags, deps.recordSource), + flags: buildRequestFlags(merged, metadataFlags), runtime: merged.runtime, meta: { ...buildMeta(merged), requestId }, }; diff --git a/src/cli.ts b/src/cli.ts index ea1b9ab2ba..9a50fbf6fe 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -190,7 +190,6 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): const replayTestReporterRuntime = await createReplayReporterForTest(ctx); const commandProgress = createCommandProgressState(); const client = createAgentDeviceClient(buildClientConfig(ctx), { - recordSource: ctx.explicitFlagKeys.has('record') ? 'explicit' : 'default', transport: createCliDaemonTransport({ command, flags: ctx.effectiveFlags, diff --git a/src/commands/command-flags.ts b/src/commands/command-flags.ts index 49b15cb6d0..e2c103d857 100644 --- a/src/commands/command-flags.ts +++ b/src/commands/command-flags.ts @@ -128,14 +128,10 @@ function buildFlags(options: InternalRequestOptions): CommandFlags { export function buildRequestFlags( options: InternalRequestOptions, metadataFlags: Partial | undefined, - recordSource: 'explicit' | 'default' = 'explicit', ): CommandFlags { return { ...buildFlags(options), ...metadataFlags, - ...(options.record === true && recordSource === 'explicit' - ? { recordingIntent: 'explicit-record' as const } - : {}), }; } diff --git a/src/mcp/command-tools.ts b/src/mcp/command-tools.ts index b8d8cba4e5..cce69527d6 100644 --- a/src/mcp/command-tools.ts +++ b/src/mcp/command-tools.ts @@ -45,7 +45,6 @@ export type ToolResult = { type CommandToolExecutorDeps = { createClient?: ( config: AgentDeviceClientConfig, - ingress: { recordSource: 'explicit' | 'default' }, ) => AgentDeviceClient | Promise; runCommand?: ( client: AgentDeviceClient, @@ -186,11 +185,7 @@ export function createCommandToolExecutor(deps: CommandToolExecutorDeps = {}): C const config = readMcpToolConfig(resolvedInput); const commandInput = stripMcpConfigFields(resolvedInput); const pinnedInput = refPins.pinInput(name, commandInput, config.client.stateDir); - const client = await createClient( - deps, - config.client, - input.record === true ? 'explicit' : 'default', - ); + const client = await createClient(deps, config.client); try { const result = await (deps.runCommand ?? runCommand)(client, name, pinnedInput); refPins.mergeCommandResult(name, result, config.client.stateDir, commandInput.session); @@ -250,15 +245,13 @@ export const commandToolExecutor = createCommandToolExecutor(); async function createClient( deps: CommandToolExecutorDeps, config: AgentDeviceClientConfig, - recordSource: 'explicit' | 'default', ): Promise { - if (deps.createClient) return await deps.createClient(config, { recordSource }); + if (deps.createClient) return await deps.createClient(config); const { createAgentDeviceClient } = await import('../agent-device-client.ts'); const { createEnsuredDaemonTransport } = await import('../daemon-client/daemon-client.ts'); // One client per tool call, so each call ensures the daemon it is about to use. const ensuring = createEnsuredDaemonTransport(); return createAgentDeviceClient(config, { - recordSource, // The client builds the same request the daemon transport takes; its type only differs in the // session being optional, which `sendToDaemon` defaults. transport: async (req, context) => diff --git a/test/integration/provider-scenarios/cli-record-publication.test.ts b/test/integration/provider-scenarios/cli-record-publication.test.ts index ff29a4cfec..cc61adaae6 100644 --- a/test/integration/provider-scenarios/cli-record-publication.test.ts +++ b/test/integration/provider-scenarios/cli-record-publication.test.ts @@ -84,7 +84,7 @@ async function repairPublication(root: string) { } test.each([false, true])( - 'CLI project record default retains a repair snapshot only with explicit opt-in=%s', + 'CLI keeps a corrective snapshot only with an explicit --record, not with a retired record config key=%s', async (explicit) => { const { root, home, project } = makeTempWorkspace(); const publication = await repairPublication(root); @@ -112,15 +112,52 @@ test.each([false, true])( useRealDaemonClient: true, }, ); - expect(result.code, result.stderr || result.stdout).not.toBe(1); - expect(await publication.commands(), JSON.stringify(result)).toEqual( - explicit ? ['snapshot', 'snapshot', 'is', 'close'] : ['snapshot', 'is', 'close'], - ); + // The retired project-config key fails closed with migration guidance, so the + // explicit `--record` call never reaches the repair session either. + expect(result.code).toBe(1); + const failureOutput = `${result.stdout}${result.stderr}`; + expect(failureOutput).toMatch(/\\"record\\" in project config file .* was removed/); + expect(failureOutput).toMatch(/--record is per-invocation only/); }, ); +test('CLI keeps an explicitly recorded corrective snapshot with no config key present', async () => { + const { root, home, project } = makeTempWorkspace(); + const publication = await repairPublication(root); + onTestFinished(() => publication.close()); + const result = await runCliCapture( + [ + 'snapshot', + '--json', + '--session', + 'default', + '--state-dir', + publication.config.stateDir, + '--daemon-transport', + 'http', + '--platform', + 'android', + '--serial', + publication.config.serial, + '--record', + ], + { + cwd: project, + env: { HOME: home, USERPROFILE: home }, + useRealDaemonClient: true, + }, + ); + expect(result.code, result.stderr || result.stdout).not.toBe(1); + expect(await publication.commands(), JSON.stringify(result)).toEqual([ + 'snapshot', + 'snapshot', + 'is', + 'close', + ]); +}); + test.each([false, true])( - 'MCP project record default retains a repair snapshot only with explicit opt-in=%s', + 'MCP keeps a corrective snapshot only with an explicit record input, not with a retired record config key=%s', async (explicit) => { const { root, home, project } = makeTempWorkspace(); const publication = await repairPublication(root); @@ -136,15 +173,15 @@ test.each([false, true])( process.chdir(project); try { const executor = createCommandToolExecutor(); - const result = await executor.execute('snapshot', { - platform: 'android', - serial: publication.config.serial, - ...(explicit ? { record: true } : {}), - }); - expect(result.isError, JSON.stringify(result)).toBe(false); - expect(await publication.commands(), JSON.stringify(result)).toEqual( - explicit ? ['snapshot', 'snapshot', 'is', 'close'] : ['snapshot', 'is', 'close'], - ); + // Config resolution runs before the command and rejects the call with the + // migration error, whether or not the tool input carries `record`. + await expect( + executor.execute('snapshot', { + platform: 'android', + serial: publication.config.serial, + ...(explicit ? { record: true } : {}), + }), + ).rejects.toThrow(/"record" in project config file .* was removed/); } finally { process.chdir(previousCwd); restoreEnv(); @@ -152,6 +189,38 @@ test.each([false, true])( }, ); +test('MCP keeps an explicitly recorded corrective snapshot with no config key present', async () => { + const { root, home, project } = makeTempWorkspace(); + const publication = await repairPublication(root); + onTestFinished(() => publication.close()); + const restoreEnv = installIsolatedCliTestEnv({ + HOME: home, + USERPROFILE: home, + AGENT_DEVICE_STATE_DIR: publication.config.stateDir, + AGENT_DEVICE_DAEMON_TRANSPORT: 'http', + }); + const previousCwd = process.cwd(); + process.chdir(project); + try { + const executor = createCommandToolExecutor(); + const result = await executor.execute('snapshot', { + platform: 'android', + serial: publication.config.serial, + record: true, + }); + expect(result.isError, JSON.stringify(result)).toBe(false); + expect(await publication.commands(), JSON.stringify(result)).toEqual([ + 'snapshot', + 'snapshot', + 'is', + 'close', + ]); + } finally { + process.chdir(previousCwd); + restoreEnv(); + } +}); + test('SDK per-call record explicitly retains a corrective snapshot', async () => { const { root } = makeTempWorkspace(); const publication = await repairPublication(root); diff --git a/test/integration/provider-scenarios/replay-repair-record-exclusion.test.ts b/test/integration/provider-scenarios/replay-repair-record-exclusion.test.ts index e1a1669344..bc04751795 100644 --- a/test/integration/provider-scenarios/replay-repair-record-exclusion.test.ts +++ b/test/integration/provider-scenarios/replay-repair-record-exclusion.test.ts @@ -133,12 +133,6 @@ async function assertRepairSegmentReadExclusion(params: { }); assertRpcOk(diagnosticRead); - const defaultRecordRead = await daemon.callCommand('get', ['text', SEARCH_SELECTOR], { - ...selection, - record: true, - }); - assertRpcOk(defaultRecordRead); - // (b) The corrective read (the wave-3 E3 shape: the diverged step is itself // a read), forced into the heal with `--record`. await daemon.client().interactions.get({ From 98d717c3916af922b6d3cef871a25cd44562b4c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 16:13:04 +0200 Subject: [PATCH 14/15] test(cli): keep the config test file at its merge-base size under the ratchet Move the retired-record config and env tests into cli-config-retired-record.test.ts, the split file mirroring the retired-key surface they cover. Fixes the test-file-size ratchet at CI run 39038581065. --- .../cli-config-retired-record.test.ts | 68 +++++++++++++++++++ src/__tests__/cli-config.test.ts | 55 --------------- 2 files changed, 68 insertions(+), 55 deletions(-) create mode 100644 src/__tests__/cli-config-retired-record.test.ts diff --git a/src/__tests__/cli-config-retired-record.test.ts b/src/__tests__/cli-config-retired-record.test.ts new file mode 100644 index 0000000000..9476b9ff34 --- /dev/null +++ b/src/__tests__/cli-config-retired-record.test.ts @@ -0,0 +1,68 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { runCliCapture } from './cli-capture.ts'; +import { makeTempWorkspace } from './cli-config-fixtures.ts'; + +/** + * The retired `record` config surface (`src/commands/schema/cli-config.ts`): + * the config key `record` and AGENT_DEVICE_RECORD were released as command + * defaults, but the repair-segment recording opt-in they feed needs + * per-invocation provenance a config default cannot carry. They fail closed + * with migration guidance instead of silently doing nothing. + */ + +test('retired record config key is refused with migration guidance on every config surface', async () => { + const { root, home, project } = makeTempWorkspace(); + fs.mkdirSync(path.join(home, '.agent-device'), { recursive: true }); + fs.writeFileSync(path.join(project, 'agent-device.json'), JSON.stringify({ record: true })); + fs.writeFileSync(path.join(home, '.agent-device', 'config.json'), JSON.stringify({})); + + const result = await runCliCapture(['snapshot'], { + cwd: project, + env: { HOME: home }, + defaultResponse: { ok: true, data: {} }, + }); + assert.equal(result.code, 1); + assert.match(result.stderr, /"record" in project config file .* was removed/); + assert.match(result.stderr, /--record is per-invocation only/); + + fs.rmSync(path.join(project, 'agent-device.json')); + fs.writeFileSync( + path.join(home, '.agent-device', 'config.json'), + JSON.stringify({ record: true }), + ); + const fromUserConfig = await runCliCapture(['snapshot'], { + cwd: project, + env: { HOME: home }, + defaultResponse: { ok: true, data: {} }, + }); + assert.equal(fromUserConfig.code, 1); + assert.match(fromUserConfig.stderr, /"record" in config file .* was removed/); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test('retired AGENT_DEVICE_RECORD fails closed only for commands that accept --record', async () => { + const { root, home, project } = makeTempWorkspace(); + const env = { HOME: home, AGENT_DEVICE_RECORD: 'true' }; + + const snapshot = await runCliCapture(['snapshot'], { + cwd: project, + env, + defaultResponse: { ok: true, data: {} }, + }); + assert.equal(snapshot.code, 1); + assert.match(snapshot.stderr, /AGENT_DEVICE_RECORD was removed/); + assert.match(snapshot.stderr, /--record is per-invocation only/); + + const unaffected = await runCliCapture(['devices', '--json'], { + cwd: project, + env, + defaultResponse: { ok: true, data: { devices: [] } }, + }); + assert.equal(unaffected.code, null); + + fs.rmSync(root, { recursive: true, force: true }); +}); diff --git a/src/__tests__/cli-config.test.ts b/src/__tests__/cli-config.test.ts index 175d3606c2..ea362cfe14 100644 --- a/src/__tests__/cli-config.test.ts +++ b/src/__tests__/cli-config.test.ts @@ -118,61 +118,6 @@ test('retired screenshotMaxSize config key is refused with migration guidance', fs.rmSync(root, { recursive: true, force: true }); }); -test('retired record config key is refused with migration guidance on every config surface', async () => { - const { root, home, project } = makeTempWorkspace(); - fs.mkdirSync(path.join(home, '.agent-device'), { recursive: true }); - fs.writeFileSync(path.join(project, 'agent-device.json'), JSON.stringify({ record: true })); - fs.writeFileSync(path.join(home, '.agent-device', 'config.json'), JSON.stringify({})); - - const result = await runCliCapture(['snapshot'], { - cwd: project, - env: { HOME: home }, - defaultResponse: { ok: true, data: {} }, - }); - assert.equal(result.code, 1); - assert.match(result.stderr, /"record" in project config file .* was removed/); - assert.match(result.stderr, /--record is per-invocation only/); - - fs.rmSync(path.join(project, 'agent-device.json')); - fs.writeFileSync( - path.join(home, '.agent-device', 'config.json'), - JSON.stringify({ record: true }), - ); - const fromUserConfig = await runCliCapture(['snapshot'], { - cwd: project, - env: { HOME: home }, - defaultResponse: { ok: true, data: {} }, - }); - assert.equal(fromUserConfig.code, 1); - assert.match(fromUserConfig.stderr, /"record" in config file .* was removed/); - - fs.writeFileSync(path.join(home, '.agent-device', 'config.json'), JSON.stringify({})); - fs.rmSync(root, { recursive: true, force: true }); -}); - -test('retired AGENT_DEVICE_RECORD fails closed only for commands that accept --record', async () => { - const { root, home, project } = makeTempWorkspace(); - const env = { HOME: home, AGENT_DEVICE_RECORD: 'true' }; - - const snapshot = await runCliCapture(['snapshot'], { - cwd: project, - env, - defaultResponse: { ok: true, data: {} }, - }); - assert.equal(snapshot.code, 1); - assert.match(snapshot.stderr, /AGENT_DEVICE_RECORD was removed/); - assert.match(snapshot.stderr, /--record is per-invocation only/); - - const unaffected = await runCliCapture(['devices', '--json'], { - cwd: project, - env, - defaultResponse: { ok: true, data: { devices: [] } }, - }); - assert.equal(unaffected.code, null); - - fs.rmSync(root, { recursive: true, force: true }); -}); - test('config can set appsFilter through canonical enum values', async () => { const { root, home, project } = makeTempWorkspace(); fs.mkdirSync(path.join(home, '.agent-device'), { recursive: true }); From 39c9885c81dcd2a739e4d6a73963c8de7551370f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 16:20:01 +0200 Subject: [PATCH 15/15] test(cli): cover the retired record key in explicit config sources An --config file and AGENT_DEVICE_CONFIG resolve through the same parseConfigObject check as user and project config; pin the refusal for both spellings. Addresses review thread discussion_r4302985179. --- .../cli-config-retired-record.test.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/__tests__/cli-config-retired-record.test.ts b/src/__tests__/cli-config-retired-record.test.ts index 9476b9ff34..3c8f0d4657 100644 --- a/src/__tests__/cli-config-retired-record.test.ts +++ b/src/__tests__/cli-config-retired-record.test.ts @@ -44,6 +44,39 @@ test('retired record config key is refused with migration guidance on every conf fs.rmSync(root, { recursive: true, force: true }); }); +test('retired record config key is refused in an explicit --config file', async () => { + const { root, home, project } = makeTempWorkspace(); + const configPath = path.join(root, 'chosen.json'); + fs.writeFileSync(configPath, JSON.stringify({ record: true })); + + const result = await runCliCapture(['snapshot', '--config', configPath], { + cwd: project, + env: { HOME: home }, + defaultResponse: { ok: true, data: {} }, + }); + assert.equal(result.code, 1); + assert.match(result.stderr, /"record" in config file .*chosen\.json was removed/); + assert.match(result.stderr, /--record is per-invocation only/); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test('retired record config key is refused in an AGENT_DEVICE_CONFIG file', async () => { + const { root, home, project } = makeTempWorkspace(); + const configPath = path.join(root, 'from-env.json'); + fs.writeFileSync(configPath, JSON.stringify({ record: true })); + + const result = await runCliCapture(['snapshot'], { + cwd: project, + env: { HOME: home, AGENT_DEVICE_CONFIG: configPath }, + defaultResponse: { ok: true, data: {} }, + }); + assert.equal(result.code, 1); + assert.match(result.stderr, /"record" in config file .*from-env\.json was removed/); + + fs.rmSync(root, { recursive: true, force: true }); +}); + test('retired AGENT_DEVICE_RECORD fails closed only for commands that accept --record', async () => { const { root, home, project } = makeTempWorkspace(); const env = { HOME: home, AGENT_DEVICE_RECORD: 'true' };