Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion packages/replay-port/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"type": "module",
"description": "Daemon-side replay port: the adapter over the ad-replay, ad-script and replay-test engines.",
"dependencies": {
"@agent-device/ad-replay": "workspace:*",
"@agent-device/ad-script": "workspace:*",
"@agent-device/capture-kit": "workspace:*",
"@agent-device/contracts": "workspace:*",
Expand All @@ -14,7 +15,8 @@
"@agent-device/kernel": "workspace:*",
"@agent-device/maestro": "workspace:*",
"@agent-device/replay-test": "workspace:*",
"@agent-device/selectors": "workspace:*"
"@agent-device/selectors": "workspace:*",
"@agent-device/session-journal": "workspace:*"
},
"exports": {
"./command-types": {
Expand Down Expand Up @@ -57,10 +59,18 @@
"types": "./src/daemon-port/session-replay-report-action.ts",
"default": "./src/daemon-port/session-replay-report-action.ts"
},
"./session-replay-resume": {
"types": "./src/daemon-port/session-replay-resume.ts",
"default": "./src/daemon-port/session-replay-resume.ts"
},
"./session-replay-runtime-artifacts": {
"types": "./src/daemon-port/session-replay-runtime-artifacts.ts",
"default": "./src/daemon-port/session-replay-runtime-artifacts.ts"
},
"./session-replay-runtime-failure-response": {
"types": "./src/daemon-port/session-replay-runtime-failure-response.ts",
"default": "./src/daemon-port/session-replay-runtime-failure-response.ts"
},
"./session-replay-suggestion-ranking": {
"types": "./src/daemon-port/session-replay-suggestion-ranking.ts",
"default": "./src/daemon-port/session-replay-suggestion-ranking.ts"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import type { SessionAction } from '@agent-device/contracts/session';
import assert from 'node:assert/strict';
import { test } from 'vitest';
import { buildReplayDivergenceResume } from '../session-replay-resume.ts';

function action(overrides: Partial<SessionAction> = {}): SessionAction {
return { ts: 0, command: 'click', positionals: ['label="Save"'], flags: {}, ...overrides };
}

test('buildReplayDivergenceResume reports a resumable generic .ad failure', () => {
const actions: SessionAction[] = [
action({ command: 'open', positionals: ['Demo'] }),
action({ command: 'click', positionals: ['label="Save"'] }),
];
// `state-repair` carries no `alternateFrom` (no recorded-action alternate),
// so this stays a clean base-shape assertion — the caution/manual
// `alternateFrom` cases are covered by the dedicated tests below.
const resume = buildReplayDivergenceResume({
failedIndex: 2,
actions,
planDigest: 'abc123',
repairHint: 'state-repair',
sessionExists: true,
});
assert.deepEqual(resume, { allowed: true, from: 2, planDigest: 'abc123' });
});
// --- repairHint 'record-and-heal' shifts `from` to failedIndex + 1 (ADR 0012

@cubic-dev-ai cubic-dev-ai Bot Sep 28, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: These mid-file comment blocks narrate implementation decisions and review history (#1262 "re-review", ADR 0012 rationales) between tests, which AGENTS.md's comment rule explicitly discourages ("No control-flow narration or review history in comments; encode invariants in names/types/boundaries/tests"). The tests already encode each boundary case in its name and assertions; keep at most a one-line note where the boundary is non-obvious (e.g., the empty-tail-without-session rule).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/replay-port/src/daemon-port/__tests__/session-replay-resume.test.ts, line 27:

<comment>These mid-file comment blocks narrate implementation decisions and review history (#1262 "re-review", ADR 0012 rationales) between tests, which AGENTS.md's comment rule explicitly discourages ("No control-flow narration or review history in comments; encode invariants in names/types/boundaries/tests"). The tests already encode each boundary case in its name and assertions; keep at most a one-line note where the boundary is non-obvious (e.g., the empty-tail-without-session rule).</comment>

<file context>
@@ -0,0 +1,172 @@
+  });
+  assert.deepEqual(resume, { allowed: true, from: 2, planDigest: 'abc123' });
+});
+// --- repairHint 'record-and-heal' shifts `from` to failedIndex + 1 (ADR 0012
+// decision 6, R2): the agent already performed the diverged step manually, so
+// resuming AT it would re-diverge on the exact same step. ---
</file context>
Fix with cubic

// decision 6, R2): the agent already performed the diverged step manually, so
// resuming AT it would re-diverge on the exact same step. ---

test('buildReplayDivergenceResume with repairHint record-and-heal resumes AFTER the failed step', () => {
const actions: SessionAction[] = [
action({ command: 'open', positionals: ['Demo'] }),
action({ command: 'click', positionals: ['label="Save"'] }),
action({ command: 'click', positionals: ['label="Confirm"'] }),
];
const resume = buildReplayDivergenceResume({
failedIndex: 2,
actions,
planDigest: 'abc123',
repairHint: 'record-and-heal',
sessionExists: true,
});
assert.deepEqual(resume, { allowed: true, from: 3, planDigest: 'abc123' });
});

test('buildReplayDivergenceResume with repairHint record-and-heal on the LAST plan step is a legal empty-tail resume', () => {
const actions: SessionAction[] = [
action({ command: 'open', positionals: ['Demo'] }),
action({ command: 'click', positionals: ['label="Save"'] }),
];
// failedIndex 2 (the last of 2 actions) shifts to from 3 = actions.length +
// 1 — there is no step 3 to run, but that is not an error: the runtime
// executes zero steps and reaches the normal end-of-plan completion path.
const resume = buildReplayDivergenceResume({
failedIndex: 2,
actions,
planDigest: 'abc123',
repairHint: 'record-and-heal',
sessionExists: true,
});
assert.deepEqual(resume, { allowed: true, from: 3, planDigest: 'abc123' });
});
// --- buildReplayDivergenceResume: `resume.alternateFrom` (#1262). The
// `caution`/`manual` dual-path's SECOND ordinal (`failedIndex + 1`), present
// ONLY when a `--from failedIndex + 1` request would actually be accepted.
// Generic `.ad` plans contain no runtime variable producers or control
// wrappers, so only the empty-tail session requirement can block it. ---

test('buildReplayDivergenceResume: caution mid-plan carries alternateFrom = failedIndex + 1', () => {
const actions: SessionAction[] = [
action({ command: 'open' }),
action({ command: 'click' }),
action({ command: 'click' }),
];
const resume = buildReplayDivergenceResume({
failedIndex: 2,
actions,
planDigest: 'abc123',
repairHint: 'caution',
sessionExists: true,
});
assert.equal(resume.allowed, true);
assert.equal(resume.from, 2); // unshifted
if (!resume.allowed) return;
assert.equal(resume.alternateFrom, 3);
});

test('buildReplayDivergenceResume: manual last-step carries alternateFrom = actions.length + 1', () => {
const actions: SessionAction[] = [action({ command: 'open' }), action({ command: 'click' })];
const resume = buildReplayDivergenceResume({
failedIndex: 2,
actions,
planDigest: 'abc123',
repairHint: 'manual',
sessionExists: true,
});
assert.equal(resume.allowed, true);
assert.equal(resume.from, 2);
if (!resume.allowed) return;
assert.equal(resume.alternateFrom, 3); // empty-tail ordinal
});

test('buildReplayDivergenceResume: record-and-heal and state-repair never carry alternateFrom (no separate recorded-action alternate)', () => {
const actions: SessionAction[] = [
action({ command: 'open' }),
action({ command: 'click' }),
action({ command: 'click' }),
];
for (const repairHint of ['record-and-heal', 'state-repair'] as const) {
const resume = buildReplayDivergenceResume({
failedIndex: 2,
actions,
planDigest: 'abc123',
repairHint,
sessionExists: true,
});
assert.equal(resume.allowed, true);
if (!resume.allowed) return;
assert.equal(resume.alternateFrom, undefined, `expected no alternateFrom for ${repairHint}`);
}
});

// --- #1262 (re-review): the EMPTY-TAIL alternate (`failedIndex + 1 >
// actions.length`) is authorizable only via the `pendingRecordAndHeal`
// watermark, which can only be stamped on a LIVE session. With NO session — a
// one-step `open` failure, or a session closed mid-replay — advertising
// `--from actions.length + 1` would be rejected as out of range, so it must
// not be emitted. A MID-PLAN alternate (in range) needs no watermark and stays
// session-independent. ---

test('buildReplayDivergenceResume: LAST-step caution/manual with NO session carries NO alternateFrom (empty-tail needs a watermark, which needs a session)', () => {
const actions: SessionAction[] = [action({ command: 'open' }), action({ command: 'click' })];
for (const repairHint of ['caution', 'manual'] as const) {
const resume = buildReplayDivergenceResume({
failedIndex: 2, // last step → alternate would be the one-past-end ordinal 3
actions,
planDigest: 'abc123',
repairHint,
sessionExists: false,
});
assert.equal(resume.allowed, true); // resuming AT the failed step (2) is still fine
if (!resume.allowed) return;
assert.equal(
resume.alternateFrom,
undefined,
`expected no empty-tail alternateFrom without a session for ${repairHint}`,
);
}
});

test('buildReplayDivergenceResume: MID-PLAN caution/manual with NO session STILL carries alternateFrom (in-range, no watermark needed)', () => {
// 3-step plan; failedIndex 2 → alternate 3 is IN RANGE (<= actions.length),
// so it needs no watermark and is emitted regardless of session existence.
const actions: SessionAction[] = [
action({ command: 'open' }),
action({ command: 'click' }),
action({ command: 'click' }),
];
for (const repairHint of ['caution', 'manual'] as const) {
const resume = buildReplayDivergenceResume({
failedIndex: 2,
actions,
planDigest: 'abc123',
repairHint,
sessionExists: false,
});
assert.equal(resume.allowed, true);
if (!resume.allowed) return;
assert.equal(resume.alternateFrom, 3, `expected mid-plan alternateFrom for ${repairHint}`);
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { test, expect } from 'vitest';
import { buildReplayDivergenceFailureResponseFromDescriptor } from '../session-replay-runtime-failure-response.ts';

test('native replay failure metadata keeps machine fields and daemon-owned paths intact', () => {
const replayPath = '/tmp/flows/ios-login.ad';
const artifactPath = '/tmp/sessions/default/screenshot-1.png';
const response = buildReplayDivergenceFailureResponseFromDescriptor({
error: {
code: 'COMMAND_FAILED',
message: 'Could not tap Continue on ios',
hint: 'Retry Continue on ios',
details: {
reason: 'not_found',
retriable: false,
supportedOn: 'ios',
},
retriable: false,
supportedOn: 'ios',
},
actionLabel: 'press Continue',
action: 'press',
positionals: ['Continue'],
step: 2,
replayPath,
artifactPaths: [artifactPath],
divergence: {},
scrubVars: [
{ name: 'MODE', value: 'on' },
{ name: 'PLATFORM', value: 'ios' },
{ name: 'SESSION', value: 'default' },
],
});

expect(response.ok).toBe(false);
if (response.ok) return;
expect(response.error.retriable).toBe(false);
expect(response.error.supportedOn).toBe('ios');
expect(response.error.details).toMatchObject({
reason: 'not_found',
retriable: false,
supportedOn: 'ios',
replayPath,
positionals: ['Continue'],
artifactPaths: [artifactPath],
});
expect(response.error.details).toHaveProperty('reason');
expect(response.error.details).not.toHaveProperty('reas<var:MODE>');

@cubic-dev-ai cubic-dev-ai Bot Sep 28, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The reas<var:MODE> assertion looks like garbage text without context. It is a real guard — the detail key reason ends in the substring on, which is MODE's scrub value, so if scrubReplayVarValues were ever applied to the details object's keys, reason would become reas<var:MODE> — but the connection is invisible and silently decays if anyone changes MODE's value in the fixture. Add a comment explaining the guard (and that it depends on reason containing the scrub value on).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/replay-port/src/daemon-port/__tests__/session-replay-runtime-failure-response.test.ts, line 47:

<comment>The `reas<var:MODE>` assertion looks like garbage text without context. It is a real guard — the detail key `reason` ends in the substring `on`, which is MODE's scrub value, so if `scrubReplayVarValues` were ever applied to the details object's keys, `reason` would become `reas<var:MODE>` — but the connection is invisible and silently decays if anyone changes MODE's value in the fixture. Add a comment explaining the guard (and that it depends on `reason` containing the scrub value `on`).</comment>

<file context>
@@ -0,0 +1,48 @@
+    artifactPaths: [artifactPath],
+  });
+  expect(response.error.details).toHaveProperty('reason');
+  expect(response.error.details).not.toHaveProperty('reas<var:MODE>');
+});
</file context>
Suggested change
expect(response.error.details).not.toHaveProperty('reas<var:MODE>');
// Guard: details keys never pass through scrubReplayVarValues. The key
// 'reason' ends with MODE's scrub value ('on'), so key-level scrubbing would
// surface here as a 'reas<var:MODE>' property; keep that only if MODE's value stays 'on'.
expect(response.error.details).not.toHaveProperty('reas<var:MODE>');
Fix with cubic

});
2 changes: 1 addition & 1 deletion packages/replay-port/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@
"declarationDir": "./dist-types",
"rootDir": "./src"
},
"include": ["src"]
"include": ["src", "../command-registry/src/global.d.ts"]
}
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions src/daemon/__tests__/replay-coordinator-ownership.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,11 @@ const RUNTIME_FILE = 'src/daemon/handlers/session-replay-command.ts';

/** The divergence-report chain: never a second `ReplayCoordinator`, never a bare `SessionStore`. */
const DIVERGENCE_CHAIN_FILES = [
'src/daemon/replay/internal/session-replay-resume.ts',
'packages/replay-port/src/daemon-port/session-replay-resume.ts',

@cubic-dev-ai cubic-dev-ai Bot Sep 28, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: These two chain files moved out of src, but listProductionSourceFiles() walks only the src root (line 54), so the createReplayCoordinator has exactly one production call site and "must not be re-exported" assertions no longer scan them — they were covered while located at src/daemon/replay/internal/. The chain-level "never imports the coordinator factory" test can't compensate: it only matches direct named value imports (importsValueBinding requires an ImportSpecifier bound to createReplayCoordinator), so a namespace import, export * re-export, or dynamic import of the coordinator from either moved file passes every assertion in this file. The files this diff deliberately places in the protected chain are now the only chain members outside the single-construction-site guarantee. Add packages/replay-port/src/daemon-port to the roots in listProductionSourceFiles so they stay under the same construction-site and re-export scans as before the move.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/daemon/__tests__/replay-coordinator-ownership.test.ts, line 35:

<comment>These two chain files moved out of `src`, but `listProductionSourceFiles()` walks only the `src` root (line 54), so the `createReplayCoordinator has exactly one production call site` and "must not be re-exported" assertions no longer scan them — they were covered while located at `src/daemon/replay/internal/`. The chain-level "never imports the coordinator factory" test can't compensate: it only matches direct named value imports (`importsValueBinding` requires an `ImportSpecifier` bound to `createReplayCoordinator`), so a namespace import, `export *` re-export, or dynamic import of the coordinator from either moved file passes every assertion in this file. The files this diff deliberately places in the protected chain are now the only chain members outside the single-construction-site guarantee. Add `packages/replay-port/src/daemon-port` to the `roots` in `listProductionSourceFiles` so they stay under the same construction-site and re-export scans as before the move.</comment>

<file context>
@@ -32,11 +32,11 @@ const RUNTIME_FILE = 'src/daemon/handlers/session-replay-command.ts';
 /** The divergence-report chain: never a second `ReplayCoordinator`, never a bare `SessionStore`. */
 const DIVERGENCE_CHAIN_FILES = [
-  'src/daemon/replay/internal/session-replay-resume.ts',
+  'packages/replay-port/src/daemon-port/session-replay-resume.ts',
   'src/daemon/replay/internal/session-replay-divergence.ts',
   'src/daemon/replay/internal/session-replay-target-verification.ts',
</file context>
Fix with cubic

@cubic-dev-ai cubic-dev-ai Bot Sep 28, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: resolveRelativeTarget returns null for any specifier that doesn't normalize under src/ (line 75), so the chain checks don't understand the package-rooted files added here. For the moved files, the the divergence-report chain rejects unresolved dynamic imports assertion (via unresolvedDynamicImportSites) classes any legitimate dynamic import — an in-package one (./replay-session-binding.ts) or a workspace one (@agent-device/...) — as "unresolved" and fails; today the files happen to contain none, so the test passes only vacuously. Conversely, the coordinator-factory/SessionStore checks can only fire on cross-tree relative specifiers, so workspace-alias imports are invisible to them. Resolve package-rooted targets into a non-null target so the dynamic-import guard discriminates real unresolved specifiers from in-repo ones, while keeping src/-rooted resolution unchanged so the cross-tree SessionStore/coordinator checks keep working.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/daemon/__tests__/replay-coordinator-ownership.test.ts, line 35:

<comment>`resolveRelativeTarget` returns null for any specifier that doesn't normalize under `src/` (line 75), so the chain checks don't understand the package-rooted files added here. For the moved files, the `the divergence-report chain rejects unresolved dynamic imports` assertion (via `unresolvedDynamicImportSites`) classes any legitimate dynamic import — an in-package one (`./replay-session-binding.ts`) or a workspace one (`@agent-device/...`) — as "unresolved" and fails; today the files happen to contain none, so the test passes only vacuously. Conversely, the coordinator-factory/SessionStore checks can only fire on cross-tree relative specifiers, so workspace-alias imports are invisible to them. Resolve package-rooted targets into a non-null `target` so the dynamic-import guard discriminates real unresolved specifiers from in-repo ones, while keeping `src/`-rooted resolution unchanged so the cross-tree SessionStore/coordinator checks keep working.</comment>

<file context>
@@ -32,11 +32,11 @@ const RUNTIME_FILE = 'src/daemon/handlers/session-replay-command.ts';
 /** The divergence-report chain: never a second `ReplayCoordinator`, never a bare `SessionStore`. */
 const DIVERGENCE_CHAIN_FILES = [
-  'src/daemon/replay/internal/session-replay-resume.ts',
+  'packages/replay-port/src/daemon-port/session-replay-resume.ts',
   'src/daemon/replay/internal/session-replay-divergence.ts',
   'src/daemon/replay/internal/session-replay-target-verification.ts',
</file context>
Fix with cubic

'src/daemon/replay/internal/session-replay-divergence.ts',
'src/daemon/replay/internal/session-replay-target-verification.ts',
'src/daemon/replay/internal/session-replay-runtime-failure.ts',
'src/daemon/replay/internal/session-replay-runtime-failure-response.ts',
'packages/replay-port/src/daemon-port/session-replay-runtime-failure-response.ts',
] as const;

type ImportSite = {
Expand Down
Loading
Loading