From 1133030b58ed38ecffc806b91a9f68e961b0b354 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 2 Aug 2026 13:13:21 +0200 Subject: [PATCH 1/4] feat: keep replay session active on request --- packages/contracts/src/cli-flags.ts | 2 + packages/contracts/src/client-replay.ts | 2 + packages/contracts/src/client-request.ts | 1 + scripts/integration-progress-model.ts | 1 + src/cli/parser/cli-help.ts | 2 +- .../cli-grammar/flag-definitions-workflow.ts | 8 ++ src/commands/cli-grammar/types.ts | 1 + src/commands/command-flags.ts | 1 + src/commands/replay/index.test.ts | 20 ++++ src/commands/replay/index.ts | 8 +- .../__tests__/session-replay-runtime.test.ts | 100 ++++++++++++++++++ .../handlers/__tests__/session-replay.test.ts | 31 ++++++ src/daemon/handlers/session-replay-runtime.ts | 33 +++--- src/daemon/handlers/session-replay.ts | 6 ++ website/docs/docs/commands.md | 2 + website/docs/docs/replay-e2e.md | 12 +++ 16 files changed, 213 insertions(+), 17 deletions(-) diff --git a/packages/contracts/src/cli-flags.ts b/packages/contracts/src/cli-flags.ts index fd1d7a75af..e7269e7c31 100644 --- a/packages/contracts/src/cli-flags.ts +++ b/packages/contracts/src/cli-flags.ts @@ -136,6 +136,8 @@ export type CliFlags = CloudProviderProfileFields & replayShellEnv?: Record; replayFrom?: number; replayPlanDigest?: string; + /** Replay: leave the session active by suppressing an authored terminal close in native .ad. */ + replayKeepSession?: boolean; failFast?: boolean; timeoutMs?: number; retries?: number; diff --git a/packages/contracts/src/client-replay.ts b/packages/contracts/src/client-replay.ts index cf6d6ea4ac..6546bd601f 100644 --- a/packages/contracts/src/client-replay.ts +++ b/packages/contracts/src/client-replay.ts @@ -30,6 +30,8 @@ export type ReplayRunOptions = AgentDeviceRequestOverrides & resumeFrom?: number; /** The `resume.planDigest` from the divergence report `resumeFrom` came from. */ resumePlanDigest?: string; + /** Leave the session active by suppressing an authored terminal `close` in native `.ad`. */ + keepSession?: boolean; /** * ADR 0012 decision 6, R1/R6: arms agent-supervised re-record repair * from this replay attempt onward. Optional string value is the healed diff --git a/packages/contracts/src/client-request.ts b/packages/contracts/src/client-request.ts index 01527eb713..0b4f23fc99 100644 --- a/packages/contracts/src/client-request.ts +++ b/packages/contracts/src/client-request.ts @@ -56,6 +56,7 @@ export type CommandExecutionOptions = Partial & { replayShellEnv?: Record; replayFrom?: number; replayPlanDigest?: string; + replayKeepSession?: boolean; failFast?: boolean; timeoutMs?: number; retries?: number; diff --git a/scripts/integration-progress-model.ts b/scripts/integration-progress-model.ts index 09918365c7..e36a59fc42 100644 --- a/scripts/integration-progress-model.ts +++ b/scripts/integration-progress-model.ts @@ -182,6 +182,7 @@ function summarizeProviderScenarioFlagCoverage(files) { ['replayEnv', 'replay/test variable injection', ['env']], ['replayFrom', 'replay resume skips completed steps (ADR 0012)', ['resumeFrom']], ['replayPlanDigest', 'replay resume plan-digest preflight binding', ['resumePlanDigest']], + ['replayKeepSession', 'native replay terminal-close suppression', ['keepSession']], ['failFast', 'test suite stops after first failure'], ['timeoutMs', 'wait/test timeout flags'], ['retries', 'test suite retry budget flows through request path'], diff --git a/src/cli/parser/cli-help.ts b/src/cli/parser/cli-help.ts index 52a3ec0e37..3891f1f46a 100644 --- a/src/cli/parser/cli-help.ts +++ b/src/cli/parser/cli-help.ts @@ -358,7 +358,7 @@ Validation and evidence: agent-device screenshot agent-device press 124 817 agent-device snapshot -i - Startup/CPU/memory/frame first pass: perf metrics --json (bare perf and metrics are aliases). Focused frame/jank health: perf frames --json. Memory-only sample: perf memory sample --json returns compact JSON with bounded top offenders. Heap/memgraph artifact escalation: perf memory snapshot --out heap.artifact; use --kind android-hprof on Android or --kind memgraph on supported Apple simulator/macOS app sessions. Android native profiling: perf cpu profile start|stop|report --kind simpleperf --out ; Android native traces: perf trace start|stop --kind perfetto --out . Artifact collectors return compact state/path/size metadata only; raw heap/profile/trace files stay on disk. Treat native perf output as the agent evidence: for example, a Perfetto stop can return state=stopped, outPath=/tmp/app.perfetto-trace, sizeBytes=5392410, and method=adb-shell-perfetto while the 5.3 MB raw trace stays in the artifact. This is better than raw dumps for agents because it is stable, bounded, and keeps large artifacts out of context. heapprofd is deferred until Perfetto plumbing is available. Replay divergence and resume: a failing replay/test step returns REPLAY_DIVERGENCE with a bounded report (screen digest, ranked selector suggestions, resume). Repair app state, then resume with replay --from --plan-digest (both from the report's resume field) to continue from the failed step without re-running earlier ones; resume never re-executes skipped steps, so app state is the caller's responsibility, and it is rejected with INVALID_ARGS when the plan digest is stale, --from is out of range, or the skipped range/target touches runtime control flow. The plan digest binds the script, its includes, the effective --platform/--target, and per-action runtime/identity. Native .ad interpolation is late-bound after planning, so changing only its values keeps the digest; Maestro environment substitution occurs during compatibility parsing and can change action inputs, includes, or control expansion, so it can change the digest. --from is replay-only; test rejects it. --update/-u no longer rewrites the script (ADR 0012) — it is a no-op kept for compatibility; every divergence already carries the same ranked suggestions. Agent-supervised repair (heal-by-doing): arm replay .ad --save-script[=] before step 1 (armed once; --from continuations do not need it again). Every divergence carries a repairHint: record-and-heal means press the correct control via a blessed @ref from the divergence's screen.refs, recorded (no --no-record), then continue with replay --from --plan-digest ; state-repair means the script is correct but app state is not, so fix state with --no-record actions, then replay --from --plan-digest to re-run the unchanged step; caution means something already matches the recorded selector, so a blind re-press may repeat the mistake; manual means no safe automated repair could be proven. (If close --save-script is run before a required resume, the repair is aborted and no script is written). While armed, read-only inspection YOU run to locate the repair target (snapshot -i, get attrs, find, is) is excluded from the healed script by default — no --no-record needed; the script's own authored get/is/find steps are unaffected and stay in the heal. If the step you are repairing is itself a read, pass --record on that one command so it lands in the heal (--record is accepted only on snapshot/get/is and a read-only find; it is mutually exclusive with --no-record). Ending the repair with close --save-script[=] writes only the steps recorded since the arming replay as , defaulting to the sibling .healed.ad — review its diff before promoting it over the original. + Startup/CPU/memory/frame first pass: perf metrics --json (bare perf and metrics are aliases). Focused frame/jank health: perf frames --json. Memory-only sample: perf memory sample --json returns compact JSON with bounded top offenders. Heap/memgraph artifact escalation: perf memory snapshot --out heap.artifact; use --kind android-hprof on Android or --kind memgraph on supported Apple simulator/macOS app sessions. Android native profiling: perf cpu profile start|stop|report --kind simpleperf --out ; Android native traces: perf trace start|stop --kind perfetto --out . Artifact collectors return compact state/path/size metadata only; raw heap/profile/trace files stay on disk. Treat native perf output as the agent evidence: for example, a Perfetto stop can return state=stopped, outPath=/tmp/app.perfetto-trace, sizeBytes=5392410, and method=adb-shell-perfetto while the 5.3 MB raw trace stays in the artifact. This is better than raw dumps for agents because it is stable, bounded, and keeps large artifacts out of context. heapprofd is deferred until Perfetto plumbing is available. Replay divergence and resume: a failing replay/test step returns REPLAY_DIVERGENCE with a bounded report (screen digest, ranked selector suggestions, resume). Repair app state, then resume with replay --from --plan-digest (both from the report's resume field) to continue from the failed step without re-running earlier ones; resume never re-executes skipped steps, so app state is the caller's responsibility, and it is rejected with INVALID_ARGS when the plan digest is stale, --from is out of range, or the skipped range/target touches runtime control flow. The plan digest binds the script, its includes, the effective --platform/--target, and per-action runtime/identity. Native .ad interpolation is late-bound after planning, so changing only its values keeps the digest; Maestro environment substitution occurs during compatibility parsing and can change action inputs, includes, or control expansion, so it can change the digest. --from is replay-only; test rejects it. Native .ad session takeover: replay .ad --keep-session suppresses exactly an authored terminal close and returns the surviving session for continued commands; interior closes still run, close-less scripts are unchanged, and test/Maestro reject the option. --update/-u no longer rewrites the script (ADR 0012) — it is a no-op kept for compatibility; every divergence already carries the same ranked suggestions. Agent-supervised repair (heal-by-doing): arm replay .ad --save-script[=] before step 1 (armed once; --from continuations do not need it again). Every divergence carries a repairHint: record-and-heal means press the correct control via a blessed @ref from the divergence's screen.refs, recorded (no --no-record), then continue with replay --from --plan-digest ; state-repair means the script is correct but app state is not, so fix state with --no-record actions, then replay --from --plan-digest to re-run the unchanged step; caution means something already matches the recorded selector, so a blind re-press may repeat the mistake; manual means no safe automated repair could be proven. (If close --save-script is run before a required resume, the repair is aborted and no script is written). While armed, read-only inspection YOU run to locate the repair target (snapshot -i, get attrs, find, is) is excluded from the healed script by default — no --no-record needed; the script's own authored get/is/find steps are unaffected and stay in the heal. If the step you are repairing is itself a read, pass --record on that one command so it lands in the heal (--record is accepted only on snapshot/get/is and a read-only find; it is mutually exclusive with --no-record). Ending the repair with close --save-script[=] writes only the steps recorded since the arming replay as , defaulting to the sibling .healed.ad — review its diff before promoting it over the original. Recording: record start/stop. The default scope is app and expects an active session created by open ; this keeps app proof videos tied to the intended app session. Use record start --scope device/system to explicitly request whole-screen capture where the selected backend supports it, such as recordings that intentionally span multiple apps, home screen, settings, or app transitions. Use --max-size to cap the longest edge and --quality medium|high to choose output quality across Android and Apple targets. By default, stop burns touch overlays into the video; use record start --hide-touches for the fastest raw recording. Android record start publishes a durable device manifest. Android adb screenrecord has a 180s platform limit, so longer Android recordings are returned as multiple MP4 chunks while the daemon stays alive; after daemon restart, record stop recovers only manifest-owned chunks and warns when gesture overlays are unavailable. For gesture-heavy iOS simulator proof videos, prefer --hide-touches because overlay timing depends on a stable runner session while gestures are executing. Tracing: trace start ./trace.log, trace stop ./trace.log. Paths are positional. Stable known flow: batch ./steps.json, not workflow batch. Inline batch JSON example: diff --git a/src/commands/cli-grammar/flag-definitions-workflow.ts b/src/commands/cli-grammar/flag-definitions-workflow.ts index c23fd58d82..3e1f453395 100644 --- a/src/commands/cli-grammar/flag-definitions-workflow.ts +++ b/src/commands/cli-grammar/flag-definitions-workflow.ts @@ -30,6 +30,14 @@ export const WORKFLOW_FLAG_DEFINITIONS: readonly FlagDefinition[] = [ 'Replay: the plan digest a --from resume must match (from a prior divergence report); mismatch, ' + 'edits, or include/platform-expansion changes fail INVALID_ARGS before any action', }, + { + key: 'replayKeepSession', + names: ['--keep-session'], + type: 'boolean', + usageLabel: '--keep-session', + usageDescription: + 'Replay: leave the session active by suppressing exactly an authored terminal close in a native .ad script; replay only, not test or Maestro YAML', + }, { key: 'replayMaestro', names: ['--maestro'], diff --git a/src/commands/cli-grammar/types.ts b/src/commands/cli-grammar/types.ts index 4dd8b7f631..074f96b9fb 100644 --- a/src/commands/cli-grammar/types.ts +++ b/src/commands/cli-grammar/types.ts @@ -37,6 +37,7 @@ export type CommandInput = Omit from?: PointInput; include?: CliFlags['networkInclude']; kind?: string; + keepSession?: boolean; locator?: string; mode?: 'in-app' | 'system' | 'full' | 'limited'; button?: ClickButton; diff --git a/src/commands/command-flags.ts b/src/commands/command-flags.ts index 423186d48a..9ac733d71a 100644 --- a/src/commands/command-flags.ts +++ b/src/commands/command-flags.ts @@ -103,6 +103,7 @@ function buildFlags(options: InternalRequestOptions): CommandFlags { replayShellEnv: options.replayShellEnv, replayFrom: options.replayFrom, replayPlanDigest: options.replayPlanDigest, + replayKeepSession: options.replayKeepSession, failFast: options.failFast, timeoutMs: options.timeoutMs, retries: options.retries, diff --git a/src/commands/replay/index.test.ts b/src/commands/replay/index.test.ts index dadbd72a0d..c1b1bec359 100644 --- a/src/commands/replay/index.test.ts +++ b/src/commands/replay/index.test.ts @@ -228,6 +228,26 @@ describe('replay resume (ADR 0012 decision 4 / migration step 5)', () => { }); }); +describe('replay --keep-session', () => { + test('projects the CLI flag through structured replay input and the daemon request', () => { + const input = replayCliReader(['./checkout.ad'], flags({ replayKeepSession: true })); + expect(input).toMatchObject({ path: './checkout.ad', keepSession: true }); + expect(replayDaemonWriter(input)).toMatchObject({ + command: 'replay', + positionals: ['./checkout.ad'], + options: { replayKeepSession: true }, + }); + expect(replayCommandMetadata.inputSchema.properties).toHaveProperty('keepSession'); + }); + + test('test exposes and forwards no keep-session option', () => { + const input = testCliReader(['./suite.ad'], flags({ replayKeepSession: true } as never)); + expect(input).not.toHaveProperty('keepSession'); + expect(testCommandMetadata.inputSchema.properties).not.toHaveProperty('keepSession'); + expect(testDaemonWriter(input).options).not.toHaveProperty('replayKeepSession'); + }); +}); + describe('replay --save-script arming (ADR 0012 decision 6, R1/R6)', () => { test('reads --save-script as a boolean flag', () => { expect(replayCliReader(['./checkout.ad'], flags({ saveScript: true }))).toMatchObject({ diff --git a/src/commands/replay/index.ts b/src/commands/replay/index.ts index 7177d9b507..c507d4969f 100644 --- a/src/commands/replay/index.ts +++ b/src/commands/replay/index.ts @@ -49,6 +49,9 @@ export const replayCommandMetadata = defineFieldCommandMetadata( // neither field — it must stay a full, deterministic suite run. resumeFrom: integerField(), resumePlanDigest: stringField(), + keepSession: booleanField( + 'Leave the session active by suppressing exactly an authored terminal close in native .ad.', + ), // ADR 0012 decision 6, R1/R6: arms agent-supervised re-record repair // from the first replay attempt; optional string value is the healed // script's output path. @@ -93,7 +96,7 @@ export const testCommandDefinition = defineExecutableCommand(testCommandMetadata const replayCliSchema = { usageOverride: 'replay | replay export [--out ]', helpDescription: - 'Replay a recorded session. For Maestro YAML compatibility flows, use replay --maestro and keep the target binding such as --platform ios on the replay command. A script with no terminal close leaves its session (and daemon) running until you close it or it idle-reaps — no different from a session opened interactively.', + 'Replay a recorded session. For Maestro YAML compatibility flows, use replay --maestro and keep the target binding such as --platform ios on the replay command. A script with no terminal close leaves its session (and daemon) running until you close it or it idle-reaps — no different from a session opened interactively. For native .ad scripts, --keep-session suppresses exactly an authored terminal close so you can continue interactively.', summary: replayCommandDescription, positionalArgs: ['path'], allowsExtraPositionals: true, @@ -103,6 +106,7 @@ const replayCliSchema = { ...METRO_RELOAD_FLAGS, 'replayFrom', 'replayPlanDigest', + 'replayKeepSession', 'timeoutMs', 'out', 'saveScript', @@ -144,6 +148,7 @@ export const replayCliReader: CliReader = (positionals, flags) => ({ bundleUrl: flags.bundleUrl, resumeFrom: flags.replayFrom, resumePlanDigest: flags.replayPlanDigest, + keepSession: flags.replayKeepSession, saveScript: flags.saveScript, force: flags.force, }); @@ -175,6 +180,7 @@ export const replayDaemonWriter: DaemonWriter = (input) => replayShellEnv: collectReplayClientShellEnv(process.env), replayFrom: input.resumeFrom, replayPlanDigest: input.resumePlanDigest, + replayKeepSession: input.keepSession, saveScript: input.saveScript, }); diff --git a/src/daemon/handlers/__tests__/session-replay-runtime.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime.test.ts index f6f9cb4eac..2624cbeb30 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime.test.ts @@ -10,6 +10,7 @@ import os from 'node:os'; import path from 'node:path'; import { runReplayScriptFile } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; +import type { DaemonRequest } from '../../types.ts'; import { dispatchCommand } from '../../../core/dispatch.ts'; import { makeIosSession, @@ -103,6 +104,105 @@ test('a replay whose terminal close removes the session reports sessionActive: f expect((response.data as { sessionActive: boolean }).sessionActive).toBe(false); }); +test('--keep-session suppresses only the authored terminal close and reports the surviving session', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-keep-session-')); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + const sessionName = 'default'; + sessionStore.set(sessionName, makeIosSession(sessionName)); + const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"', 'close']); + const commands: string[] = []; + + const response = await runReplayScriptFile({ + req: baseReq({ positionals: [filePath], flags: { replayKeepSession: true } }), + sessionName, + logPath: path.join(root, 'daemon.log'), + sessionStore, + invoke: async (req) => { + commands.push(req.command); + if (req.command === 'close') sessionStore.delete(sessionName); + return { ok: true, data: {} }; + }, + }); + + expect(commands).toEqual(['open', 'click']); + expect(response.ok).toBe(true); + if (!response.ok) return; + expect(sessionStore.get(sessionName)).toBeDefined(); + expect((response.data as { sessionActive: boolean }).sessionActive).toBe(true); +}); + +test('--keep-session preserves an interior close instead of broad command filtering', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-keep-interior-close-')); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + const sessionName = 'default'; + sessionStore.set(sessionName, makeIosSession(sessionName)); + const filePath = writeReplayFile(root, ['open "Demo"', 'close', 'open "Next"']); + const commands: string[] = []; + + const response = await runReplayScriptFile({ + req: baseReq({ positionals: [filePath], flags: { replayKeepSession: true } }), + sessionName, + logPath: path.join(root, 'daemon.log'), + sessionStore, + invoke: async (req) => { + commands.push(req.command); + return { ok: true, data: {} }; + }, + }); + + expect(response.ok).toBe(true); + expect(commands).toEqual(['open', 'close', 'open']); +}); + +test('--keep-session is a no-op for an already close-less script', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-keep-close-less-')); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + const sessionName = 'default'; + sessionStore.set(sessionName, makeIosSession(sessionName)); + const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); + const invoke = vi.fn(async (_req: DaemonRequest) => ({ ok: true as const, data: {} })); + + const response = await runReplayScriptFile({ + req: baseReq({ positionals: [filePath], flags: { replayKeepSession: true } }), + sessionName, + logPath: path.join(root, 'daemon.log'), + sessionStore, + invoke, + }); + + expect(response.ok).toBe(true); + expect(invoke.mock.calls.map(([req]) => req.command)).toEqual(['open', 'click']); + if (!response.ok) return; + expect((response.data as { sessionActive: boolean }).sessionActive).toBe(true); +}); + +test('--keep-session rejects Maestro YAML before engine dispatch', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-keep-maestro-')); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + const sessionName = 'default'; + sessionStore.set(sessionName, makeIosSession(sessionName)); + const filePath = path.join(root, 'flow.yaml'); + fs.writeFileSync(filePath, ['appId: com.example.app', '---', '- launchApp'].join('\n')); + const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); + + const response = await runReplayScriptFile({ + req: baseReq({ + positionals: [filePath], + flags: { replayBackend: 'maestro', replayKeepSession: true }, + }), + sessionName, + logPath: path.join(root, 'daemon.log'), + sessionStore, + invoke, + }); + + expect(response).toMatchObject({ + ok: false, + error: { code: 'INVALID_ARGS', message: expect.stringContaining('--keep-session') }, + }); + expect(invoke).not.toHaveBeenCalled(); +}); + test('Maestro YAML uses the typed engine while .ad remains generic', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-typed-maestro-route-')); const sessionStore = new SessionStore(path.join(root, 'sessions')); diff --git a/src/daemon/handlers/__tests__/session-replay.test.ts b/src/daemon/handlers/__tests__/session-replay.test.ts index 7f4e767883..8bbd94f85c 100644 --- a/src/daemon/handlers/__tests__/session-replay.test.ts +++ b/src/daemon/handlers/__tests__/session-replay.test.ts @@ -272,6 +272,37 @@ test('test --record-video records each replay attempt on the generated test sess // --- ADR 0012 decision 4 / migration step 5: `--from` is replay-only --- +test('test rejects raw --keep-session with INVALID_ARGS before running the suite', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-test-keep-session-rejected-')); + const replayPath = path.join(root, 'flow.ad'); + fs.writeFileSync(replayPath, 'open "Demo"\n'); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); + + const response = await handleSessionReplayCommands({ + req: { + token: 'token', + session: 'default', + command: 'test', + positionals: [replayPath], + flags: { replayKeepSession: true }, + meta: { cwd: root }, + }, + sessionName: 'default', + logPath: path.join(root, 'daemon.log'), + sessionStore, + leaseRegistry: new LeaseRegistry(), + invoke, + }); + + if (!response) throw new Error('Expected response'); + assert.equal(response.ok, false); + if (response.ok) return; + assert.equal(response.error.code, 'INVALID_ARGS'); + assert.match(response.error.message, /--keep-session/); + assert.equal(invoke.mock.calls.length, 0); +}); + test('test rejects --from with INVALID_ARGS before running the suite', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-test-from-rejected-')); const replayPath = path.join(root, 'flow.ad'); diff --git a/src/daemon/handlers/session-replay-runtime.ts b/src/daemon/handlers/session-replay-runtime.ts index 295e676d96..fb9bc543bf 100644 --- a/src/daemon/handlers/session-replay-runtime.ts +++ b/src/daemon/handlers/session-replay-runtime.ts @@ -231,6 +231,12 @@ export async function runReplayScriptFile(params: { return errorResponse('INVALID_ARGS', maestroBackendRequiredMessage('replay', filePath)); } if (resolveReplayFormat(resolved, req.flags?.replayBackend) === 'maestro') { + if (req.flags?.replayKeepSession === true) { + return errorResponse( + 'INVALID_ARGS', + '--keep-session is supported only for native .ad replay; Maestro YAML owns its lifecycle.', + ); + } if (coordinator.view()?.repairBoundary !== undefined) { return errorResponse( 'INVALID_ARGS', @@ -365,10 +371,11 @@ async function executeReplayActions( // session created by `open` before treating `close` as lifecycle. armSaveScript(); if ( - isRepairArmedTerminalClose({ + shouldSkipTerminalClose({ action, index, totalActions: actions.length, + keepSession: params.req.flags?.replayKeepSession === true, coordinator: stepContext.coordinator, }) ) { @@ -787,28 +794,24 @@ function preflightSaveScriptTarget(params: { } /** - * ADR 0012 decision 6 (Fix 3): the source plan's own terminal `close` is - * lifecycle, not a script step to replay, while a repair is armed — the agent - * finalizes the transaction with `close --save-script` instead - * (`session-close.ts`). Replaying the recorded `close` here would dispatch it - * as an ordinary step: it tears the session down (and, absent Fix 1/2, could - * even publish or diverge) before the agent gets that chance. Skipped exactly - * like the `replay` pseudo-command just above it in the loop — never - * dispatched, never divergence-checked, and (like that skip) not counted out - * of `replayedCount`. Checked against session state, not this invocation's - * own flags, matching R2: a repair stays armed across separate `--from` legs - * regardless of whether `--save-script` is repeated on each one. + * The one native replay lifecycle seam for an authored terminal `close`. + * `--keep-session` suppresses it so callers can take over the live session; + * ADR 0012 repair suppresses it so the agent can finalize through + * `close --save-script`. Interior closes retain authored semantics. Repair is + * checked against session state (not only this leg's flags), preserving R2 + * across separate `--from` continuations. */ -function isRepairArmedTerminalClose(params: { +function shouldSkipTerminalClose(params: { action: SessionAction; index: number; totalActions: number; + keepSession: boolean; coordinator: ReplayCoordinator; }): boolean { - const { action, index, totalActions, coordinator } = params; + const { action, index, totalActions, keepSession, coordinator } = params; if (action.command !== 'close') return false; if (index !== totalActions - 1) return false; - return coordinator.view()?.repairBoundary !== undefined; + return keepSession || coordinator.view()?.repairBoundary !== undefined; } /** diff --git a/src/daemon/handlers/session-replay.ts b/src/daemon/handlers/session-replay.ts index 51e53e62d8..b6141a4e17 100644 --- a/src/daemon/handlers/session-replay.ts +++ b/src/daemon/handlers/session-replay.ts @@ -136,6 +136,12 @@ export async function handleSessionReplayCommands(params: { } if (req.command === 'test') { + if (req.flags?.replayKeepSession !== undefined) { + return errorResponse( + 'INVALID_ARGS', + 'test does not support --keep-session; suite attempts own their cleanup. Run one native .ad script directly with replay --keep-session.', + ); + } // ADR 0012 decision 4 / migration step 5: `--from` is replay-only. `test` // shares replay execution (below, via a nested `command: 'replay'` // request per matched file) but must remain a full, deterministic suite diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index dc15526e2f..d6db91d6e2 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -456,9 +456,11 @@ agent-device replay ./session.ad # Run deterministic replay from .ad script agent-device test ./suite # Run every .ad file in a folder or glob serially agent-device test ./suite --timeout 60000 --retries 1 agent-device replay ./session.ad --from 4 --plan-digest # Execute step 4; if already completed, use the next safe index with this digest +agent-device replay ./session.ad --keep-session # Suppress its terminal close and continue interactively ``` - `replay` runs deterministic `.ad` scripts. +- `replay --keep-session` suppresses exactly an authored terminal `close` in native `.ad`; interior closes still run, and a close-less script is unchanged. The option is rejected by `test` and Maestro YAML. - `test` runs one or more `.ad` scripts as a serial suite from files, directories, or glob inputs. - `test --platform ` filters suite files by `context platform=...` metadata instead of overriding the script target. - `test --timeout ` and `test --retries ` apply per script attempt; `context timeout=...` and `context retries=...` can be declared inside the `.ad` header. Retries are capped at `3`, duplicate metadata keys are rejected, and timeouts are cooperative. diff --git a/website/docs/docs/replay-e2e.md b/website/docs/docs/replay-e2e.md index 5b5975a0cc..b4d6bdc02b 100644 --- a/website/docs/docs/replay-e2e.md +++ b/website/docs/docs/replay-e2e.md @@ -47,6 +47,17 @@ agent-device replay ~/.agent-device/sessions/e2e-2026-02-09T12-00-00-000Z.ad --s ``` - Replay reads `.ad` scripts. +- A script without a terminal `close` already leaves its session active. For an existing script that + does end in `close`, pass `--keep-session` to suppress only that final action and continue with + interactive commands in the same session: + + ```bash + agent-device replay ./checkout.ad --session e2e-run --keep-session + agent-device snapshot -i --session e2e-run + ``` + + Interior `close` actions still run. The flag is intentionally unavailable to `test` because suite + attempts own cleanup, and it is rejected for Maestro YAML because that runtime owns its lifecycle. ## Run Maestro compatibility flows @@ -72,6 +83,7 @@ Boundaries: - Failure diagnostics: resolved targets and `runFlow` paths are rendered, while `inputText` payloads remain hidden; do not place secrets in diagnostic identifiers. - Trust: `runScript` executes trusted scripts, may make `http.post` network requests, and is not a security sandbox; output keys cannot contain a dot. - Errors and tracking: unsupported commands and fields fail with source context when available; open a focused issue only when implementation work is planned. +- Session takeover: `--keep-session` is a native `.ad` replay option and is rejected for Maestro YAML. See [ADR 0015](https://github.com/callstack/agent-device/blob/main/docs/adr/0015-direct-maestro-engine.md) for architecture, performance tradeoffs, and deliberate deviations. If a missing feature matters for your suite, [open a focused issue](https://github.com/callstack/agent-device/issues/new) with a small flow snippet. From 5dacea4a9b1331b12819d8d1216253fff3390205 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 2 Aug 2026 17:03:36 +0200 Subject: [PATCH 2/4] test: cover replay keep-session provider route --- .../active-session-script-publication.test.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/test/integration/provider-scenarios/active-session-script-publication.test.ts b/test/integration/provider-scenarios/active-session-script-publication.test.ts index 8d11dd651b..21f6bfe00c 100644 --- a/test/integration/provider-scenarios/active-session-script-publication.test.ts +++ b/test/integration/provider-scenarios/active-session-script-publication.test.ts @@ -61,6 +61,33 @@ test('provider route publishes and replays an open-to-destination script with a }); }, 20_000); +test('provider route can suppress an authored terminal close for a live replay handoff', async () => { + await withProviderScenarioResource(createAndroidSettingsWorld, async (world) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-keep-session-provider-')); + const scriptPath = path.join(root, 'settings-with-close.ad'); + fs.writeFileSync(scriptPath, ['open "settings"', 'close', ''].join('\n')); + const client = world.daemon.client(); + try { + const replay = await client.replay.run({ + path: scriptPath, + keepSession: true, + ...world.selection, + }); + assert.equal(replay.session, 'default'); + assert.equal(replay.sessionActive, true); + assert.ok(world.daemon.session(), 'keepSession must preserve the replay-opened session'); + + const takeoverSnapshot = await client.capture.snapshot({ interactiveOnly: true }); + assert.ok(takeoverSnapshot.nodes.some((node) => node.label === 'Search')); + + await client.sessions.close(); + assert.equal(world.daemon.session(), undefined); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}, 20_000); + // #1349 / ADR 0016 reshuffled-screen false-pass regression: the destination // guard must prove recorded landmark IDENTITY, not selector existence. The // replay lands on a reshuffled screen that still contains a node labeled From 1eaec722b3445ff27c8f8750d7081456d41d78dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 2 Aug 2026 17:30:33 +0200 Subject: [PATCH 3/4] fix: make replay session handoff reliable --- packages/contracts/src/replay.ts | 8 +- .../__tests__/session-replay-runtime.test.ts | 59 +++++++++++- .../handlers/__tests__/session-replay.test.ts | 57 ++++++++++- src/daemon/handlers/session-replay-runtime.ts | 94 +++++++++++++------ .../handlers/session-replay-test-policy.ts | 27 ++++++ src/daemon/handlers/session-replay.ts | 34 ++----- 6 files changed, 215 insertions(+), 64 deletions(-) create mode 100644 src/daemon/handlers/session-replay-test-policy.ts diff --git a/packages/contracts/src/replay.ts b/packages/contracts/src/replay.ts index bcbc45d640..23af84eb65 100644 --- a/packages/contracts/src/replay.ts +++ b/packages/contracts/src/replay.ts @@ -7,10 +7,10 @@ export type ReplayCommandResult = { session: string; /** * True iff `session` still exists in the daemon's session store when the - * response is built — i.e. the replayed script had no terminal `close` - * (ADR 0016's consumption contract). The client uses this, not script - * parsing, to decide whether an owned one-shot daemon must stay alive so - * the caller can keep addressing this session. + * response is built. This remains true when replay suppresses an authored + * terminal `close` for an explicit live-session handoff. The client uses + * this, not script parsing, to decide whether an owned one-shot daemon must + * stay alive so the caller can keep addressing this session. */ sessionActive: boolean; artifactPaths: string[]; diff --git a/src/daemon/handlers/__tests__/session-replay-runtime.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime.test.ts index 2624cbeb30..9eb5aaa576 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime.test.ts @@ -74,7 +74,60 @@ test('a close-less replay reports sessionActive: true (real producer, session st expect(response.ok).toBe(true); if (!response.ok) return; expect(sessionStore.get(sessionName)).toBeDefined(); - expect((response.data as { sessionActive: boolean }).sessionActive).toBe(true); + expect(response.data).toMatchObject({ sessionActive: true, replayed: 2 }); +}); + +test('--keep-session suppresses a close that is terminal among executable actions', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-keep-marker-tail-')); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + const sessionName = 'default'; + sessionStore.set(sessionName, makeIosSession(sessionName)); + const filePath = writeReplayFile(root, ['open "Demo"', 'close', 'replay "./nested-flow.ad"']); + const commands: string[] = []; + + const response = await runReplayScriptFile({ + req: baseReq({ positionals: [filePath], flags: { replayKeepSession: true } }), + sessionName, + logPath: path.join(root, 'daemon.log'), + sessionStore, + invoke: async (req) => { + commands.push(req.command); + if (req.command === 'close') sessionStore.delete(sessionName); + return { ok: true, data: {} }; + }, + }); + + expect(response.ok).toBe(true); + expect(commands).toEqual(['open']); + if (!response.ok) return; + expect(response.data).toMatchObject({ sessionActive: true, replayed: 1 }); +}); + +test('--keep-session fails explicitly when the completed replay has no live session', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-keep-postcondition-')); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + const sessionName = 'default'; + sessionStore.set(sessionName, makeIosSession(sessionName)); + const filePath = writeReplayFile(root, ['open "Demo"', 'click "Log out"']); + + const response = await runReplayScriptFile({ + req: baseReq({ positionals: [filePath], flags: { replayKeepSession: true } }), + sessionName, + logPath: path.join(root, 'daemon.log'), + sessionStore, + invoke: async (req) => { + if (req.command === 'click') sessionStore.delete(sessionName); + return { ok: true, data: {} }; + }, + }); + + expect(response).toMatchObject({ + ok: false, + error: { + code: 'COMMAND_FAILED', + message: expect.stringContaining('--keep-session could not preserve session'), + }, + }); }); test('a replay whose terminal close removes the session reports sessionActive: false', async () => { @@ -128,7 +181,7 @@ test('--keep-session suppresses only the authored terminal close and reports the expect(response.ok).toBe(true); if (!response.ok) return; expect(sessionStore.get(sessionName)).toBeDefined(); - expect((response.data as { sessionActive: boolean }).sessionActive).toBe(true); + expect(response.data).toMatchObject({ sessionActive: true, replayed: 2 }); }); test('--keep-session preserves an interior close instead of broad command filtering', async () => { @@ -225,7 +278,7 @@ test('Maestro YAML uses the typed engine while .ad remains generic', async () => const yamlResponse = await runReplayScriptFile({ req: baseReq({ positionals: [yamlPath], - flags: { replayBackend: 'maestro', platform: 'ios' }, + flags: { replayBackend: 'maestro', platform: 'ios', replayKeepSession: false }, }), sessionName, logPath: path.join(root, 'daemon.log'), diff --git a/src/daemon/handlers/__tests__/session-replay.test.ts b/src/daemon/handlers/__tests__/session-replay.test.ts index 8bbd94f85c..0805efda59 100644 --- a/src/daemon/handlers/__tests__/session-replay.test.ts +++ b/src/daemon/handlers/__tests__/session-replay.test.ts @@ -8,6 +8,8 @@ import { LeaseRegistry } from '../../lease-registry.ts'; import type { DaemonRequest, DaemonResponse } from '../../types.ts'; import { makeIosSession } from '../../../__tests__/test-utils/index.ts'; import { buildNestedReplayFlags, handleSessionReplayCommands } from '../session-replay.ts'; +import { REPLAY_ONLY_TEST_FLAG_REJECTIONS } from '../session-replay-test-policy.ts'; +import { replayCommandFamily } from '../../../commands/replay/index.ts'; const recordTraceMocks = vi.hoisted(() => ({ handleRecordCommand: vi.fn(), @@ -209,7 +211,7 @@ test('buildNestedReplayFlags strips test-only recordVideo before replay actions assert.deepEqual(result, { platform: 'ios' }); }); -test('test --record-video records each replay attempt on the generated test session', async () => { +test('test normalizes false replay-only booleans while recording each replay attempt', async () => { vi.useFakeTimers({ now: 1_000 }); const { root, replayPath, sessionStore, nestedRequests, events } = createRecordVideoFixture(); installMockRecordingHandler(sessionStore, { recordingPath: '', events }); @@ -220,7 +222,13 @@ test('test --record-video records each replay attempt on the generated test sess session: 'default', command: 'test', positionals: [replayPath], - flags: { recordVideo: true, artifactsDir: path.join(root, 'artifacts') }, + flags: { + recordVideo: true, + replayKeepSession: false, + saveScript: false, + force: false, + artifactsDir: path.join(root, 'artifacts'), + }, meta: { cwd: root, requestId: 'record-video-suite' }, }, sessionName: 'default', @@ -272,6 +280,20 @@ test('test --record-video records each replay attempt on the generated test sess // --- ADR 0012 decision 4 / migration step 5: `--from` is replay-only --- +test('raw test-request guards enumerate every daemon-visible replay-only CLI flag', () => { + const replayFlags = replayCommandFamily.cliSchemas.replay?.allowedFlags ?? []; + const testFlags = new Set(replayCommandFamily.cliSchemas.test?.allowedFlags ?? []); + const clientOnlyReplayFlags = new Set(['out']); + const expectedDaemonFlags = replayFlags + .filter((flag) => !testFlags.has(flag) && !clientOnlyReplayFlags.has(flag)) + .sort(); + + const guardedDaemonFlags = REPLAY_ONLY_TEST_FLAG_REJECTIONS.flatMap( + (rejection) => rejection.keys, + ).sort(); + assert.deepEqual(guardedDaemonFlags, expectedDaemonFlags); +}); + test('test rejects raw --keep-session with INVALID_ARGS before running the suite', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-test-keep-session-rejected-')); const replayPath = path.join(root, 'flow.ad'); @@ -396,3 +418,34 @@ test('test rejects --save-script with INVALID_ARGS before running the suite', as assert.equal(response.error.code, 'INVALID_ARGS'); assert.match(response.error.message, /--save-script/); }); + +test('test rejects raw --force without --save-script before running the suite', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-test-force-rejected-')); + const replayPath = path.join(root, 'flow.ad'); + fs.writeFileSync(replayPath, 'open "Demo"\n'); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); + + const response = await handleSessionReplayCommands({ + req: { + token: 'token', + session: 'default', + command: 'test', + positionals: [replayPath], + flags: { force: true }, + meta: { cwd: root }, + }, + sessionName: 'default', + logPath: path.join(root, 'daemon.log'), + sessionStore, + leaseRegistry: new LeaseRegistry(), + invoke, + }); + + if (!response) throw new Error('Expected response'); + assert.equal(response.ok, false); + if (response.ok) return; + assert.equal(response.error.code, 'INVALID_ARGS'); + assert.match(response.error.message, /--force/); + assert.equal(invoke.mock.calls.length, 0); +}); diff --git a/src/daemon/handlers/session-replay-runtime.ts b/src/daemon/handlers/session-replay-runtime.ts index fb9bc543bf..858a9a069c 100644 --- a/src/daemon/handlers/session-replay-runtime.ts +++ b/src/daemon/handlers/session-replay-runtime.ts @@ -220,6 +220,7 @@ export async function runReplayScriptFile(params: { } const startedAt = Date.now(); + const keepSession = req.flags?.replayKeepSession === true; let resolved = ''; const artifactPaths = new Set(); // #1478 P4b: the one locked coordinator this request reaches the repair @@ -231,7 +232,7 @@ export async function runReplayScriptFile(params: { return errorResponse('INVALID_ARGS', maestroBackendRequiredMessage('replay', filePath)); } if (resolveReplayFormat(resolved, req.flags?.replayBackend) === 'maestro') { - if (req.flags?.replayKeepSession === true) { + if (keepSession) { return errorResponse( 'INVALID_ARGS', '--keep-session is supported only for native .ad replay; Maestro YAML owns its lifecycle.', @@ -252,6 +253,7 @@ export async function runReplayScriptFile(params: { tracePath, resolved, coordinator, + keepSession, }); if (!planPreparation.ok) return planPreparation.response; const { @@ -264,6 +266,7 @@ export async function runReplayScriptFile(params: { scope, actionTracePath, snapshotDiagnosticSamples, + suppressedTerminalCloseIndex, } = planPreparation.value; const sessionPreparation = prepareReplaySession({ req, @@ -308,6 +311,7 @@ export async function runReplayScriptFile(params: { snapshotDiagnosticSamples, onStep, armSaveScript: sessionPreparation.armSaveScript, + suppressedTerminalCloseIndex, }); if (failure) return failure; return completeReplayRun({ @@ -320,6 +324,8 @@ export async function runReplayScriptFile(params: { snapshotDiagnosticSamples, armSaveScript: sessionPreparation.armSaveScript, coordinator, + keepSession, + suppressedTerminalCloseIndex, }); } catch (err) { const appErr = asAppError(err); @@ -348,6 +354,7 @@ type ReplayActionExecution = { snapshotDiagnosticSamples: SnapshotTimingSample[]; onStep: ReplayTestAttemptStepSink | undefined; armSaveScript: () => void; + suppressedTerminalCloseIndex: number | undefined; }; async function executeReplayActions( @@ -363,6 +370,7 @@ async function executeReplayActions( snapshotDiagnosticSamples, onStep, armSaveScript, + suppressedTerminalCloseIndex, } = params; for (let index = entryIndex; index < actions.length; index += 1) { const action = actions[index]; @@ -370,17 +378,7 @@ async function executeReplayActions( // Arm before checking terminal close so `[open, close]` records the // session created by `open` before treating `close` as lifecycle. armSaveScript(); - if ( - shouldSkipTerminalClose({ - action, - index, - totalActions: actions.length, - keepSession: params.req.flags?.replayKeepSession === true, - coordinator: stepContext.coordinator, - }) - ) { - continue; - } + if (index === suppressedTerminalCloseIndex) continue; onStep?.(replayActionStep(index, actions.length, action)); const sampleStart = readSessionSnapshotSampleCount(sessionStore, sessionName); const response = await resolveReplayStepResponse(stepContext, action, index, [ @@ -441,6 +439,8 @@ function completeReplayRun(params: { snapshotDiagnosticSamples: SnapshotTimingSample[]; armSaveScript: () => void; coordinator: ReplayCoordinator; + keepSession: boolean; + suppressedTerminalCloseIndex: number | undefined; }): DaemonResponse { const { startedAt, @@ -452,11 +452,24 @@ function completeReplayRun(params: { snapshotDiagnosticSamples, armSaveScript, coordinator, + keepSession, + suppressedTerminalCloseIndex, } = params; armSaveScript(); coordinator.markCompleteIfArmed(); const completedSession = sessionStore.get(sessionName); - const replayedCount = actions.length - entryIndex; + if (keepSession && !completedSession) { + return errorResponse( + 'COMMAND_FAILED', + `Replay completed but --keep-session could not preserve session "${sessionName}". Run the script again after checking which action closed the session.`, + artifactPaths.size > 0 ? { artifactPaths: [...artifactPaths] } : undefined, + ); + } + const replayedCount = countExecutedReplayActions({ + actions, + entryIndex, + suppressedTerminalCloseIndex, + }); const snapshotDiagnosticsSummary = summarizeSnapshotTimingSamples(snapshotDiagnosticSamples); return { ok: true, @@ -521,6 +534,7 @@ type PreparedReplayPlan = { scope: ReplayVarScope; actionTracePath: string | undefined; snapshotDiagnosticSamples: SnapshotTimingSample[]; + suppressedTerminalCloseIndex: number | undefined; }; type ParsedReplayInput = ReturnType; @@ -532,8 +546,9 @@ function prepareReplayPlan(params: { tracePath: string | undefined; resolved: string; coordinator: ReplayCoordinator; + keepSession: boolean; }): { ok: true; value: PreparedReplayPlan } | { ok: false; response: DaemonResponse } { - const { req, sessionName, sessionStore, tracePath, resolved, coordinator } = params; + const { req, sessionName, sessionStore, tracePath, resolved, coordinator, keepSession } = params; const parsedResult = parseReplayScript(resolved, req); if (!parsedResult.ok) return parsedResult; const parsed = parsedResult.value; @@ -571,6 +586,12 @@ function prepareReplayPlan(params: { scope: buildPreparedReplayScope({ req, replayReq, sessionName, resolved, metadata }), actionTracePath: tracePath ?? preEntrySession?.trace?.outPath, snapshotDiagnosticSamples: [], + suppressedTerminalCloseIndex: resolveSuppressedTerminalCloseIndex({ + actions, + keepSession, + saveScript: req.flags?.saveScript, + repairActive: coordinator.view()?.repairBoundary !== undefined, + }), }, }; } @@ -794,24 +815,37 @@ function preflightSaveScriptTarget(params: { } /** - * The one native replay lifecycle seam for an authored terminal `close`. - * `--keep-session` suppresses it so callers can take over the live session; - * ADR 0012 repair suppresses it so the agent can finalize through - * `close --save-script`. Interior closes retain authored semantics. Repair is - * checked against session state (not only this leg's flags), preserving R2 - * across separate `--from` continuations. + * Resolves the one native replay lifecycle seam once per plan. Terminal means + * the last executable action, because nested `replay` markers are plan + * metadata and never dispatch. The suppressed close is therefore neither + * divergence-checked nor included in the successful `replayed` count. */ -function shouldSkipTerminalClose(params: { - action: SessionAction; - index: number; - totalActions: number; +function resolveSuppressedTerminalCloseIndex(params: { + actions: SessionAction[]; keepSession: boolean; - coordinator: ReplayCoordinator; -}): boolean { - const { action, index, totalActions, keepSession, coordinator } = params; - if (action.command !== 'close') return false; - if (index !== totalActions - 1) return false; - return keepSession || coordinator.view()?.repairBoundary !== undefined; + saveScript: boolean | string | undefined; + repairActive: boolean; +}): number | undefined { + if (!params.keepSession && !params.saveScript && !params.repairActive) return undefined; + for (let index = params.actions.length - 1; index >= 0; index -= 1) { + const action = params.actions[index]; + if (!isExecutableReplayAction(action)) continue; + return action.command === 'close' ? index : undefined; + } + return undefined; +} + +function countExecutedReplayActions(params: { + actions: SessionAction[]; + entryIndex: number; + suppressedTerminalCloseIndex: number | undefined; +}): number { + let count = 0; + for (let index = params.entryIndex; index < params.actions.length; index += 1) { + if (index === params.suppressedTerminalCloseIndex) continue; + if (isExecutableReplayAction(params.actions[index])) count += 1; + } + return count; } /** diff --git a/src/daemon/handlers/session-replay-test-policy.ts b/src/daemon/handlers/session-replay-test-policy.ts new file mode 100644 index 0000000000..e641c54c0e --- /dev/null +++ b/src/daemon/handlers/session-replay-test-policy.ts @@ -0,0 +1,27 @@ +import type { CommandFlags } from '../../core/dispatch.ts'; + +export const REPLAY_ONLY_TEST_FLAG_REJECTIONS = [ + { + keys: ['replayKeepSession'], + requested: (flags: CommandFlags) => flags.replayKeepSession === true, + message: + 'test does not support --keep-session; suite attempts own their cleanup. Run one native .ad script directly with replay --keep-session.', + }, + { + keys: ['replayFrom', 'replayPlanDigest'], + requested: (flags: CommandFlags) => + flags.replayFrom !== undefined || flags.replayPlanDigest !== undefined, + message: + 'test does not support --from/--plan-digest; resume is replay-only. Run the failing script directly with replay --from.', + }, + { + keys: ['saveScript', 'force'], + requested: (flags: CommandFlags) => Boolean(flags.saveScript) || flags.force === true, + message: + 'test does not support --save-script/--force; the agent-supervised repair loop is replay-only. Repair the failing script directly with replay --save-script.', + }, +] as const satisfies readonly { + keys: readonly (keyof CommandFlags)[]; + requested: (flags: CommandFlags) => boolean; + message: string; +}[]; diff --git a/src/daemon/handlers/session-replay.ts b/src/daemon/handlers/session-replay.ts index b6141a4e17..733100665d 100644 --- a/src/daemon/handlers/session-replay.ts +++ b/src/daemon/handlers/session-replay.ts @@ -35,6 +35,7 @@ import { finalizeReplayTestVideoRecording, startReplayTestVideoRecordingIfReady, } from './session-replay-video-recording.ts'; +import { REPLAY_ONLY_TEST_FLAG_REJECTIONS } from './session-replay-test-policy.ts'; /** * Binds one replay-test attempt to daemon request cancellation (#1478 P3b). @@ -136,31 +137,14 @@ export async function handleSessionReplayCommands(params: { } if (req.command === 'test') { - if (req.flags?.replayKeepSession !== undefined) { - return errorResponse( - 'INVALID_ARGS', - 'test does not support --keep-session; suite attempts own their cleanup. Run one native .ad script directly with replay --keep-session.', - ); - } - // ADR 0012 decision 4 / migration step 5: `--from` is replay-only. `test` - // shares replay execution (below, via a nested `command: 'replay'` - // request per matched file) but must remain a full, deterministic suite - // run, so this is the one place that still knows the ORIGINAL command. - if (req.flags?.replayFrom !== undefined || req.flags?.replayPlanDigest !== undefined) { - return errorResponse( - 'INVALID_ARGS', - 'test does not support --from/--plan-digest; resume is replay-only. Run the failing script directly with replay --from.', - ); - } - // ADR 0012 decision 6: `--save-script` (the agent-supervised repair loop) - // is replay-only for the same reason — `buildNestedReplayFlags` would fan - // it into every per-file nested replay, arming recording across a whole - // suite run. Repair a single failing script with `replay --save-script`. - if (req.flags?.saveScript !== undefined) { - return errorResponse( - 'INVALID_ARGS', - 'test does not support --save-script; the agent-supervised repair loop is replay-only. Repair the failing script directly with replay --save-script.', - ); + // `test` shares replay execution below, but replay-only flags must not fan + // into every nested suite attempt. Keep the raw-daemon defense declarative + // and aligned with the command grammar; the CLI rejects these earlier. + const flags = req.flags ?? {}; + for (const rejection of REPLAY_ONLY_TEST_FLAG_REJECTIONS) { + if (rejection.requested(flags)) { + return errorResponse('INVALID_ARGS', rejection.message); + } } // Translating flags can reject them (mutually exclusive or non-positive shard counts). // That rejection has always surfaced as an INVALID_ARGS response, so it is caught here From 01f284a8a79f91fe18365c00b4e198f5f3be9002 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 2 Aug 2026 18:16:24 +0200 Subject: [PATCH 4/4] refactor(daemon): extract the replay terminal-lifecycle policy module (#1554 review) session-replay-runtime.ts was already over the 500-line extract-before-adding-behavior tripwire before this PR; the keep-session/repair terminal-close decision, its live-session postcondition, and the dispatched-action count pushed it further past budget. Move that policy into a focused session-replay-terminal-lifecycle.ts (isExecutableReplayAction, resolveSuppressedTerminalCloseIndex, countExecutedReplayActions, requireLiveSessionForKeepSession) so the runtime file stays orchestration-only, and mirror its PR-added unit tests into session-replay-terminal-lifecycle.test.ts. Pure extraction: no assertions changed. --- .../__tests__/session-replay-runtime.test.ts | 153 ---------------- .../session-replay-terminal-lifecycle.test.ts | 170 ++++++++++++++++++ src/daemon/handlers/session-replay-runtime.ts | 58 ++---- .../session-replay-terminal-lifecycle.ts | 65 +++++++ 4 files changed, 248 insertions(+), 198 deletions(-) create mode 100644 src/daemon/handlers/__tests__/session-replay-terminal-lifecycle.test.ts create mode 100644 src/daemon/handlers/session-replay-terminal-lifecycle.ts diff --git a/src/daemon/handlers/__tests__/session-replay-runtime.test.ts b/src/daemon/handlers/__tests__/session-replay-runtime.test.ts index 9eb5aaa576..681c46cce1 100644 --- a/src/daemon/handlers/__tests__/session-replay-runtime.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-runtime.test.ts @@ -10,7 +10,6 @@ import os from 'node:os'; import path from 'node:path'; import { runReplayScriptFile } from '../session-replay-runtime.ts'; import { SessionStore } from '../../session-store.ts'; -import type { DaemonRequest } from '../../types.ts'; import { dispatchCommand } from '../../../core/dispatch.ts'; import { makeIosSession, @@ -77,59 +76,6 @@ test('a close-less replay reports sessionActive: true (real producer, session st expect(response.data).toMatchObject({ sessionActive: true, replayed: 2 }); }); -test('--keep-session suppresses a close that is terminal among executable actions', async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-keep-marker-tail-')); - const sessionStore = new SessionStore(path.join(root, 'sessions')); - const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); - const filePath = writeReplayFile(root, ['open "Demo"', 'close', 'replay "./nested-flow.ad"']); - const commands: string[] = []; - - const response = await runReplayScriptFile({ - req: baseReq({ positionals: [filePath], flags: { replayKeepSession: true } }), - sessionName, - logPath: path.join(root, 'daemon.log'), - sessionStore, - invoke: async (req) => { - commands.push(req.command); - if (req.command === 'close') sessionStore.delete(sessionName); - return { ok: true, data: {} }; - }, - }); - - expect(response.ok).toBe(true); - expect(commands).toEqual(['open']); - if (!response.ok) return; - expect(response.data).toMatchObject({ sessionActive: true, replayed: 1 }); -}); - -test('--keep-session fails explicitly when the completed replay has no live session', async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-keep-postcondition-')); - const sessionStore = new SessionStore(path.join(root, 'sessions')); - const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); - const filePath = writeReplayFile(root, ['open "Demo"', 'click "Log out"']); - - const response = await runReplayScriptFile({ - req: baseReq({ positionals: [filePath], flags: { replayKeepSession: true } }), - sessionName, - logPath: path.join(root, 'daemon.log'), - sessionStore, - invoke: async (req) => { - if (req.command === 'click') sessionStore.delete(sessionName); - return { ok: true, data: {} }; - }, - }); - - expect(response).toMatchObject({ - ok: false, - error: { - code: 'COMMAND_FAILED', - message: expect.stringContaining('--keep-session could not preserve session'), - }, - }); -}); - test('a replay whose terminal close removes the session reports sessionActive: false', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-session-closed-')); const sessionStore = new SessionStore(path.join(root, 'sessions')); @@ -157,105 +103,6 @@ test('a replay whose terminal close removes the session reports sessionActive: f expect((response.data as { sessionActive: boolean }).sessionActive).toBe(false); }); -test('--keep-session suppresses only the authored terminal close and reports the surviving session', async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-keep-session-')); - const sessionStore = new SessionStore(path.join(root, 'sessions')); - const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); - const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"', 'close']); - const commands: string[] = []; - - const response = await runReplayScriptFile({ - req: baseReq({ positionals: [filePath], flags: { replayKeepSession: true } }), - sessionName, - logPath: path.join(root, 'daemon.log'), - sessionStore, - invoke: async (req) => { - commands.push(req.command); - if (req.command === 'close') sessionStore.delete(sessionName); - return { ok: true, data: {} }; - }, - }); - - expect(commands).toEqual(['open', 'click']); - expect(response.ok).toBe(true); - if (!response.ok) return; - expect(sessionStore.get(sessionName)).toBeDefined(); - expect(response.data).toMatchObject({ sessionActive: true, replayed: 2 }); -}); - -test('--keep-session preserves an interior close instead of broad command filtering', async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-keep-interior-close-')); - const sessionStore = new SessionStore(path.join(root, 'sessions')); - const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); - const filePath = writeReplayFile(root, ['open "Demo"', 'close', 'open "Next"']); - const commands: string[] = []; - - const response = await runReplayScriptFile({ - req: baseReq({ positionals: [filePath], flags: { replayKeepSession: true } }), - sessionName, - logPath: path.join(root, 'daemon.log'), - sessionStore, - invoke: async (req) => { - commands.push(req.command); - return { ok: true, data: {} }; - }, - }); - - expect(response.ok).toBe(true); - expect(commands).toEqual(['open', 'close', 'open']); -}); - -test('--keep-session is a no-op for an already close-less script', async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-keep-close-less-')); - const sessionStore = new SessionStore(path.join(root, 'sessions')); - const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); - const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); - const invoke = vi.fn(async (_req: DaemonRequest) => ({ ok: true as const, data: {} })); - - const response = await runReplayScriptFile({ - req: baseReq({ positionals: [filePath], flags: { replayKeepSession: true } }), - sessionName, - logPath: path.join(root, 'daemon.log'), - sessionStore, - invoke, - }); - - expect(response.ok).toBe(true); - expect(invoke.mock.calls.map(([req]) => req.command)).toEqual(['open', 'click']); - if (!response.ok) return; - expect((response.data as { sessionActive: boolean }).sessionActive).toBe(true); -}); - -test('--keep-session rejects Maestro YAML before engine dispatch', async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-keep-maestro-')); - const sessionStore = new SessionStore(path.join(root, 'sessions')); - const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); - const filePath = path.join(root, 'flow.yaml'); - fs.writeFileSync(filePath, ['appId: com.example.app', '---', '- launchApp'].join('\n')); - const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); - - const response = await runReplayScriptFile({ - req: baseReq({ - positionals: [filePath], - flags: { replayBackend: 'maestro', replayKeepSession: true }, - }), - sessionName, - logPath: path.join(root, 'daemon.log'), - sessionStore, - invoke, - }); - - expect(response).toMatchObject({ - ok: false, - error: { code: 'INVALID_ARGS', message: expect.stringContaining('--keep-session') }, - }); - expect(invoke).not.toHaveBeenCalled(); -}); - test('Maestro YAML uses the typed engine while .ad remains generic', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-typed-maestro-route-')); const sessionStore = new SessionStore(path.join(root, 'sessions')); diff --git a/src/daemon/handlers/__tests__/session-replay-terminal-lifecycle.test.ts b/src/daemon/handlers/__tests__/session-replay-terminal-lifecycle.test.ts new file mode 100644 index 0000000000..2fa1bcef09 --- /dev/null +++ b/src/daemon/handlers/__tests__/session-replay-terminal-lifecycle.test.ts @@ -0,0 +1,170 @@ +import { test, expect, vi } from 'vitest'; + +vi.mock('../../../core/dispatch.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, dispatchCommand: vi.fn(async () => ({})), resolveTargetDevice: vi.fn() }; +}); + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { runReplayScriptFile } from '../session-replay-runtime.ts'; +import { SessionStore } from '../../session-store.ts'; +import type { DaemonRequest } from '../../types.ts'; +import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; +import { + baseReplayRequest as baseReq, + writeReplayFile, +} from './session-replay-runtime.fixtures.ts'; + +test('--keep-session suppresses a close that is terminal among executable actions', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-keep-marker-tail-')); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + const sessionName = 'default'; + sessionStore.set(sessionName, makeIosSession(sessionName)); + const filePath = writeReplayFile(root, ['open "Demo"', 'close', 'replay "./nested-flow.ad"']); + const commands: string[] = []; + + const response = await runReplayScriptFile({ + req: baseReq({ positionals: [filePath], flags: { replayKeepSession: true } }), + sessionName, + logPath: path.join(root, 'daemon.log'), + sessionStore, + invoke: async (req) => { + commands.push(req.command); + if (req.command === 'close') sessionStore.delete(sessionName); + return { ok: true, data: {} }; + }, + }); + + expect(response.ok).toBe(true); + expect(commands).toEqual(['open']); + if (!response.ok) return; + expect(response.data).toMatchObject({ sessionActive: true, replayed: 1 }); +}); + +test('--keep-session fails explicitly when the completed replay has no live session', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-keep-postcondition-')); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + const sessionName = 'default'; + sessionStore.set(sessionName, makeIosSession(sessionName)); + const filePath = writeReplayFile(root, ['open "Demo"', 'click "Log out"']); + + const response = await runReplayScriptFile({ + req: baseReq({ positionals: [filePath], flags: { replayKeepSession: true } }), + sessionName, + logPath: path.join(root, 'daemon.log'), + sessionStore, + invoke: async (req) => { + if (req.command === 'click') sessionStore.delete(sessionName); + return { ok: true, data: {} }; + }, + }); + + expect(response).toMatchObject({ + ok: false, + error: { + code: 'COMMAND_FAILED', + message: expect.stringContaining('--keep-session could not preserve session'), + }, + }); +}); + +test('--keep-session suppresses only the authored terminal close and reports the surviving session', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-keep-session-')); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + const sessionName = 'default'; + sessionStore.set(sessionName, makeIosSession(sessionName)); + const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"', 'close']); + const commands: string[] = []; + + const response = await runReplayScriptFile({ + req: baseReq({ positionals: [filePath], flags: { replayKeepSession: true } }), + sessionName, + logPath: path.join(root, 'daemon.log'), + sessionStore, + invoke: async (req) => { + commands.push(req.command); + if (req.command === 'close') sessionStore.delete(sessionName); + return { ok: true, data: {} }; + }, + }); + + expect(commands).toEqual(['open', 'click']); + expect(response.ok).toBe(true); + if (!response.ok) return; + expect(sessionStore.get(sessionName)).toBeDefined(); + expect(response.data).toMatchObject({ sessionActive: true, replayed: 2 }); +}); + +test('--keep-session preserves an interior close instead of broad command filtering', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-keep-interior-close-')); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + const sessionName = 'default'; + sessionStore.set(sessionName, makeIosSession(sessionName)); + const filePath = writeReplayFile(root, ['open "Demo"', 'close', 'open "Next"']); + const commands: string[] = []; + + const response = await runReplayScriptFile({ + req: baseReq({ positionals: [filePath], flags: { replayKeepSession: true } }), + sessionName, + logPath: path.join(root, 'daemon.log'), + sessionStore, + invoke: async (req) => { + commands.push(req.command); + return { ok: true, data: {} }; + }, + }); + + expect(response.ok).toBe(true); + expect(commands).toEqual(['open', 'close', 'open']); +}); + +test('--keep-session is a no-op for an already close-less script', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-keep-close-less-')); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + const sessionName = 'default'; + sessionStore.set(sessionName, makeIosSession(sessionName)); + const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); + const invoke = vi.fn(async (_req: DaemonRequest) => ({ ok: true as const, data: {} })); + + const response = await runReplayScriptFile({ + req: baseReq({ positionals: [filePath], flags: { replayKeepSession: true } }), + sessionName, + logPath: path.join(root, 'daemon.log'), + sessionStore, + invoke, + }); + + expect(response.ok).toBe(true); + expect(invoke.mock.calls.map(([req]) => req.command)).toEqual(['open', 'click']); + if (!response.ok) return; + expect((response.data as { sessionActive: boolean }).sessionActive).toBe(true); +}); + +test('--keep-session rejects Maestro YAML before engine dispatch', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-keep-maestro-')); + const sessionStore = new SessionStore(path.join(root, 'sessions')); + const sessionName = 'default'; + sessionStore.set(sessionName, makeIosSession(sessionName)); + const filePath = path.join(root, 'flow.yaml'); + fs.writeFileSync(filePath, ['appId: com.example.app', '---', '- launchApp'].join('\n')); + const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); + + const response = await runReplayScriptFile({ + req: baseReq({ + positionals: [filePath], + flags: { replayBackend: 'maestro', replayKeepSession: true }, + }), + sessionName, + logPath: path.join(root, 'daemon.log'), + sessionStore, + invoke, + }); + + expect(response).toMatchObject({ + ok: false, + error: { code: 'INVALID_ARGS', message: expect.stringContaining('--keep-session') }, + }); + expect(invoke).not.toHaveBeenCalled(); +}); diff --git a/src/daemon/handlers/session-replay-runtime.ts b/src/daemon/handlers/session-replay-runtime.ts index 858a9a069c..9b484ce6e2 100644 --- a/src/daemon/handlers/session-replay-runtime.ts +++ b/src/daemon/handlers/session-replay-runtime.ts @@ -64,6 +64,12 @@ import { healedScriptSiblingPath, type ReplayCoordinator, } from '../session-replay-coordinator.ts'; +import { + countExecutedReplayActions, + isExecutableReplayAction, + requireLiveSessionForKeepSession, + resolveSuppressedTerminalCloseIndex, +} from './session-replay-terminal-lifecycle.ts'; /** Per-run invariants for a single replay step (ADR 0012 step 4 verify + dispatch + guard). */ type ReplayStepContext = { @@ -394,10 +400,6 @@ async function executeReplayActions( return undefined; } -function isExecutableReplayAction(action: SessionAction | undefined): action is SessionAction { - return Boolean(action && action.command !== 'replay'); -} - async function buildReplayActionFailure( params: ReplayActionExecution, action: SessionAction, @@ -458,13 +460,13 @@ function completeReplayRun(params: { armSaveScript(); coordinator.markCompleteIfArmed(); const completedSession = sessionStore.get(sessionName); - if (keepSession && !completedSession) { - return errorResponse( - 'COMMAND_FAILED', - `Replay completed but --keep-session could not preserve session "${sessionName}". Run the script again after checking which action closed the session.`, - artifactPaths.size > 0 ? { artifactPaths: [...artifactPaths] } : undefined, - ); - } + const keepSessionFailure = requireLiveSessionForKeepSession({ + keepSession, + sessionName, + completedSession, + artifactPaths, + }); + if (keepSessionFailure) return keepSessionFailure; const replayedCount = countExecutedReplayActions({ actions, entryIndex, @@ -814,40 +816,6 @@ function preflightSaveScriptTarget(params: { ); } -/** - * Resolves the one native replay lifecycle seam once per plan. Terminal means - * the last executable action, because nested `replay` markers are plan - * metadata and never dispatch. The suppressed close is therefore neither - * divergence-checked nor included in the successful `replayed` count. - */ -function resolveSuppressedTerminalCloseIndex(params: { - actions: SessionAction[]; - keepSession: boolean; - saveScript: boolean | string | undefined; - repairActive: boolean; -}): number | undefined { - if (!params.keepSession && !params.saveScript && !params.repairActive) return undefined; - for (let index = params.actions.length - 1; index >= 0; index -= 1) { - const action = params.actions[index]; - if (!isExecutableReplayAction(action)) continue; - return action.command === 'close' ? index : undefined; - } - return undefined; -} - -function countExecutedReplayActions(params: { - actions: SessionAction[]; - entryIndex: number; - suppressedTerminalCloseIndex: number | undefined; -}): number { - let count = 0; - for (let index = params.entryIndex; index < params.actions.length; index += 1) { - if (index === params.suppressedTerminalCloseIndex) continue; - if (isExecutableReplayAction(params.actions[index])) count += 1; - } - return count; -} - /** * ADR 0012 decision 6, R1/R6: returns a per-step armer that sets * `recordSession` and stamps the repair-run boundary watermark ONCE, through diff --git a/src/daemon/handlers/session-replay-terminal-lifecycle.ts b/src/daemon/handlers/session-replay-terminal-lifecycle.ts new file mode 100644 index 0000000000..580c4ce7d4 --- /dev/null +++ b/src/daemon/handlers/session-replay-terminal-lifecycle.ts @@ -0,0 +1,65 @@ +import type { DaemonResponse, SessionAction, SessionState } from '../types.ts'; +import { errorResponse } from './response.ts'; + +/** A dispatchable step: nested `replay` markers are plan metadata and never dispatch. */ +export function isExecutableReplayAction( + action: SessionAction | undefined, +): action is SessionAction { + return Boolean(action && action.command !== 'replay'); +} + +/** + * Resolves the one native replay lifecycle seam once per plan. Terminal means + * the last executable action, because nested `replay` markers are plan + * metadata and never dispatch. The suppressed close is therefore neither + * divergence-checked nor included in the successful `replayed` count. + */ +export function resolveSuppressedTerminalCloseIndex(params: { + actions: SessionAction[]; + keepSession: boolean; + saveScript: boolean | string | undefined; + repairActive: boolean; +}): number | undefined { + if (!params.keepSession && !params.saveScript && !params.repairActive) return undefined; + for (let index = params.actions.length - 1; index >= 0; index -= 1) { + const action = params.actions[index]; + if (!isExecutableReplayAction(action)) continue; + return action.command === 'close' ? index : undefined; + } + return undefined; +} + +export function countExecutedReplayActions(params: { + actions: SessionAction[]; + entryIndex: number; + suppressedTerminalCloseIndex: number | undefined; +}): number { + let count = 0; + for (let index = params.entryIndex; index < params.actions.length; index += 1) { + if (index === params.suppressedTerminalCloseIndex) continue; + if (isExecutableReplayAction(params.actions[index])) count += 1; + } + return count; +} + +/** + * `--keep-session`'s postcondition: a suppressed terminal close only ever + * promises a live session, so a session that is gone by completion anyway + * (some other action closed or otherwise removed it) must fail loudly rather + * than silently report `sessionActive: false` as if `--keep-session` had + * never been requested. + */ +export function requireLiveSessionForKeepSession(params: { + keepSession: boolean; + sessionName: string; + completedSession: SessionState | undefined; + artifactPaths: Set; +}): DaemonResponse | undefined { + const { keepSession, sessionName, completedSession, artifactPaths } = params; + if (!keepSession || completedSession) return undefined; + return errorResponse( + 'COMMAND_FAILED', + `Replay completed but --keep-session could not preserve session "${sessionName}". Run the script again after checking which action closed the session.`, + artifactPaths.size > 0 ? { artifactPaths: [...artifactPaths] } : undefined, + ); +}