Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
e970562
refactor(replay): move the dependency-free engine leaves into package…
thymikee Aug 2, 2026
0573f62
chore(layering): register packages/ad-replay in the workspace and DAG
thymikee Aug 2, 2026
0779e39
refactor(replay): define the three-operation replay selector port wit…
thymikee Aug 2, 2026
a185b31
refactor(daemon): route replay handlers through the selector port (#1…
thymikee Aug 2, 2026
765f438
refactor(replay): split target verification into engine policy and da…
thymikee Aug 2, 2026
e159587
refactor(replay): move the .ad step loop behind inspectAdReplay/runAd…
thymikee Aug 2, 2026
f5da8d6
refactor(replay): lock the ad-replay façade to its real consumers (#1…
thymikee Aug 2, 2026
9ba5e82
test(replay): prove shared-id demotion on both selector-port adapters…
thymikee Aug 2, 2026
78f5102
fix(replay): restore invalid replayBackend rejection on the native pa…
thymikee Aug 2, 2026
bd266d0
refactor(replay): move shared .ad vocabulary to its owner, packages/a…
thymikee Aug 2, 2026
4ebdebd
refactor(replay): neutral step/run outcomes and digest/resume behind …
thymikee Aug 2, 2026
87c3db3
refactor(replay): relocate classifyTargetBindingMatch and pin the ad-…
thymikee Aug 2, 2026
69ee0d0
refactor(replay): drive target verification from the engine step loop…
thymikee Aug 2, 2026
d9272cc
refactor(daemon): decompose the replay adapter's two over-threshold f…
thymikee Aug 2, 2026
2f52a56
refactor(replay): fold #1554's keep-session terminal-lifecycle policy…
thymikee Aug 2, 2026
8e20e3e
refactor(daemon): decompose session-replay-runtime.ts into three modu…
thymikee Aug 2, 2026
1e706e5
test(replay): cover pre-step artifact ordering and resume-before-muta…
thymikee Aug 2, 2026
b3b9ae5
fix(ad-replay): enforce the exact two-entrypoint facade (#1555 review…
thymikee Aug 3, 2026
3e93f31
fix(ad-replay): translate wire failures before the engine boundary (#…
thymikee Aug 3, 2026
fe0ee91
fix(ad-replay): move variable semantics/planning behind runAdReplay (…
thymikee Aug 3, 2026
b1983f5
fix(ad-script): make ${VAR} interpolation a linear scanner
thymikee Aug 3, 2026
e4c4798
refactor(ad-replay): typed façade replaces the zero-type rule (#1555 …
thymikee Aug 3, 2026
23bb320
fix(ad-replay): honor the selector port's own contract in the parse gate
thymikee Aug 3, 2026
6dba5a5
refactor(ad-replay): split step-loop.ts and shrink the daemon adapter…
thymikee Aug 3, 2026
c36cd1d
test(ad-replay): package-local tests for resume.ts/target-verificatio…
thymikee Aug 3, 2026
00d375c
refactor(ad-replay): compute scrub values once per step, one name end…
thymikee Aug 3, 2026
efa2585
fix(daemon): make lastObservation genuinely per-step, not per-run
thymikee Aug 3, 2026
9b8472c
docs(ad-replay): fix decayed review-changelog comments naming defunct…
thymikee Aug 3, 2026
310a084
refactor(ad-script): move declaredScriptPlatform to its natural share…
thymikee Aug 3, 2026
2875c05
docs(ad-replay): fix package.json description to match the current fa…
thymikee Aug 3, 2026
6a69554
refactor(daemon): fold the step-support fragment back into the engine…
thymikee Aug 3, 2026
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
8 changes: 8 additions & 0 deletions fallow-baselines/health.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@
"count": 1
}
},
"src/__tests__/test-utils/in-memory-replay-selector-port.ts": {
"complexity_moderate": {
"count": 2
},
"crap_moderate": {
"count": 2
}
},
"src/cli-schema/cli-config.ts": {
"crap_moderate": {
"count": 1
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@
"check:unit": "pnpm check:contention-retry && pnpm test:unit && pnpm test:smoke",
"check": "pnpm check:tooling && pnpm check:fallow && pnpm check:unit",
"prepack": "pnpm check:mcp-metadata && pnpm package:npm",
"typecheck": "tsc -b packages/xml packages/kernel packages/contracts packages/ad-script packages/maestro packages/replay-test packages/provider-webdriver packages/provider-limrun && tsc -p tsconfig.json && tsc -p examples/sdk/tsconfig.json",
"typecheck": "tsc -b packages/xml packages/kernel packages/contracts packages/ad-script packages/ad-replay packages/maestro packages/replay-test packages/provider-webdriver packages/provider-limrun && tsc -p tsconfig.json && tsc -p examples/sdk/tsconfig.json",
"test-app:install": "pnpm install --dir examples/test-app",
"test-app:start": "pnpm --dir examples/test-app start",
"test-app:ios": "pnpm --dir examples/test-app ios",
Expand Down Expand Up @@ -245,6 +245,7 @@
"yaml": "^2.9.0"
},
"devDependencies": {
"@agent-device/ad-replay": "workspace:*",
"@agent-device/ad-script": "workspace:*",
"@agent-device/contracts": "workspace:*",
"@agent-device/kernel": "workspace:*",
Expand Down
19 changes: 19 additions & 0 deletions packages/ad-replay/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "@agent-device/ad-replay",
"version": "0.0.0",
"private": true,
"sideEffects": false,
"type": "module",
"description": "Private native .ad replay engine for agent-device: manifest inspection (inspectAdReplay) and the step-loop execution engine (runAdReplay), plus the neutral AdReplayStepRuntime vocabulary the daemon adapter implements.",
"dependencies": {
"@agent-device/ad-script": "workspace:*",
"@agent-device/contracts": "workspace:*",
"@agent-device/kernel": "workspace:*"
},
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
}
}
118 changes: 118 additions & 0 deletions packages/ad-replay/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* The `ad-replay` package façade (#1478 P5 stage D — narrowed; report-action/
* suggestion-ranking/vars/identity-vocabulary further narrowed by the P5
* review pass; plan-digest/resume and `classifyTargetBindingMatch` further
* narrowed by the #1555 review pass, "complete the binding façade instead of
* documenting deviations"; the target-verification policy functions further
* narrowed by the #1555 review's R3 pass, "target verification must happen
* INSIDE the engine"). `scripts/layering/package-boundaries.test.ts` asserts
* this file's exact export list — see "the real tree parses, declares, and
* passes R11" — so a stray export (including one this parser cannot
* enumerate a name for, like `export *`) fails that gate, not just a comment
* mismatch.
*
* The binding design (issue comment 5156017698) is two value entrypoints —
* `inspectAdReplay` + `runAdReplay` — plus the neutral vocabulary their
* signatures are built from, exported by name (#1555 structural-quality
* review, "typed façade replaces the zero-type rule"): a package a root
* consumer must integrate against through hand-derived `Parameters<...>`/
* `ReturnType<...>` gymnastics in a SINGLE allowed root module
* (`src/daemon/ad-replay-facade-types.ts`, since deleted) is a shim tax, not
* an isolation win — every derived name still had to be re-exported from that
* one file for every other root module to use, and every daemon-side type
* that shadowed an engine type by hand (`TargetVerificationEntry`,
* `TargetClassificationOutcome`, `TargetBindingFailureEvidence`,
* `ReplayVerifiedTargetGuard`, plus a `toDaemonEvidence` copy translator
* between mutable and readonly array shapes) was a duplicate definition that
* could silently drift from the type it mirrored. `packages/maestro`'s
* façade (`facade-execution.ts`/`facade-runtime-port.ts`/…) is the precedent:
* a package boundary is enforced by an exact, gate-pinned export LIST, not by
* exporting zero types. The gate below now pins values AND types together,
* so a stray widening — a type accidentally exported, or one accidentally
* dropped that a root file was still deriving by hand — fails loudly either
* way.
*
* `inspectAdReplay` is the read-only `.ad` manifest reader — the plan-digest
* hash (`plan-digest.ts`, `computeReplayPlanDigest`) and the `--from`/
* `--plan-digest` resume-point math (`resume.ts`, `resolveReplayEntryIndex`)
* are internal-only; the manifest carries the digest as `planDigest` and the
* resume math as a `resolveEntryIndex` closure instead, so
* `session-replay-runtime-plan.ts`'s `prepareReplayPlan` and
* `request-router-repair-expired.test.ts` read them off the manifest rather
* than importing the underlying functions. `AdReplayManifest` is its return
* type and `AdReplayVarSources` is `runAdReplay`'s `${VAR}` scope-input
* shape — both named here since `session-replay-runtime-plan.ts` threads them
* by name across its own helper signatures.
*
* `runAdReplay` is the `.ad` step loop; `AdReplayStepRuntime` is the runtime
* capability bag the daemon adapter
* (`session-replay-runtime-engine-adapter.ts`) implements to thread it,
* including the `ReplaySelectorPort` instance every daemon call site that
* threads a port value names by the SAME type. Two adapters implement the
* port: the production adapter (`src/daemon/replay-selector-port.ts`) and
* the in-memory adapter for this package's own contract suite
* (`src/__tests__/test-utils/in-memory-replay-selector-port.ts` — relocated
* there, #1478 P5 stage D, because package-internal code may not "reach back
* into root `src/`", R11, once its only remaining consumer was a root test).
*
* `./internal/target-verification.ts`'s four policy functions
* (`planPostResolutionTargetVerification`, `planPreDispatchTargetVerification`,
* `deriveReplayTargetGuardMismatchEvidence`, `deriveWaitLandmarkMismatchEvidence`)
* stay engine-private — they are called only from `./internal/verify-dispatch.ts`'s
* `verifyAndDispatchStep`, never the daemon — but the TYPED evidence shapes
* they consume (`AdReplayGuardMismatchEvidence`, `AdReplayLandmarkMismatchEvidence`)
* and the classification/guard/binding-evidence/verification-routing shapes
* `verifyAndDispatchStep` exchanges with the daemon's `AdReplayStepRuntime`
* implementation (`AdReplayVerificationEntry`, `AdReplayTargetClassification`,
* `AdReplayTargetBindingEvidence`, `AdReplayVerifiedTargetGuard`,
* `AdReplayDispatchGuard`, `AdReplayDispatchOutcome`) ARE named here: the
* daemon builds/reads real values of these shapes directly now (routing in
* `session-replay-target-verification.ts`, wire-narrowing in
* `session-replay-runtime-engine-adapter.ts`) rather than re-declaring a
* structurally-identical twin per module.
*
* `${VAR}` scope/planning: the engine builds the `${VAR}` scope (via
* `@agent-device/ad-script`) from the request's `varSources` and resolves
* each action exactly once per step, handing the daemon's `dispatchStep`/
* `beginTargetVerification` capabilities the RESOLVED action — never a raw
* action plus a scope for the daemon to interpolate itself (#1555 review P1,
* "move variable semantics/planning behind the replay entrypoint"). The
* `${VAR}`-scrub values a divergence report redacts (`AdReplayScrubValue`)
* are threaded the same direction, as an explicit argument on each
* build*Failure/handleActionFailure capability, computed ONCE per run from
* the engine's own live scope — never recomputed daemon-side from a second
* scope object, and never re-collected per call site.
*/

export { inspectAdReplay } from './internal/inspect.ts';
export type { AdReplayManifest } from './internal/inspect.ts';

export { runAdReplay } from './internal/step-loop.ts';

export type {
AdReplayDispatchGuard,
AdReplayDispatchOutcome,
AdReplayScrubValue,
AdReplayStepFailure,
AdReplayStepRuntime,
AdReplayTargetBindingEvidence,
AdReplayTargetClassification,
AdReplayVarSources,
AdReplayVerificationEntry,
AdReplayVerifiedTargetGuard,
} from './internal/runtime-port-types.ts';

export type {
AdReplayGuardMismatchEvidence,
AdReplayLandmarkMismatchEvidence,
} from './internal/target-verification.ts';

export type {
ReplayRecordedTargetDisambiguation,
ReplayRecordedTargetPolicy,
ReplayRecordedTargetResolution,
ReplaySelectorCandidateOptions,
ReplaySelectorExpressionOutcome,
ReplaySelectorGrammar,
ReplaySelectorPort,
} from './internal/selector-port.ts';
209 changes: 209 additions & 0 deletions packages/ad-replay/src/internal/__tests__/resume.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import {
resolveReplayEntryIndex,
type AdReplayEntryIndexParams,
type PendingRecordAndHeal,
} from '../resume.ts';

/**
* #1555 structural-quality review ("package-local tests... resume.ts and
* cover its branches; counterfactual per docs/agents/testing.md on at least
* the rejection path"): `resolveReplayEntryIndex` was previously exercised
* only transitively, through the daemon's
* `session-replay-runtime-plan.test.ts` (`resolveReplayPlanEntryIndex`
* wrapping the manifest's `resolveEntryIndex` closure). This suite covers
* the pure resume-point math directly, at package level, cheaper than the
* daemon round trip.
*/

const PLAN_DIGEST = 'a'.repeat(64);
const OTHER_DIGEST = 'b'.repeat(64);
const ACTION_COUNT = 5;

function params(overrides: Partial<AdReplayEntryIndexParams> = {}): AdReplayEntryIndexParams {
return {
from: undefined,
digest: undefined,
pendingRecordAndHeal: undefined,
sessionActionsLength: 0,
...overrides,
};
}

test('no --from and no --plan-digest resolves to the plan start (entry index 0)', () => {
const result = resolveReplayEntryIndex(params(), ACTION_COUNT, PLAN_DIGEST);
assert.deepEqual(result, { ok: true, value: 0 });
});

test('--from without --plan-digest is rejected, and the reverse pairing too', () => {
const fromOnly = resolveReplayEntryIndex(params({ from: 2 }), ACTION_COUNT, PLAN_DIGEST);
assert.equal(fromOnly.ok, false);
if (fromOnly.ok) throw new Error('unreachable');
assert.match(fromOnly.message, /--from requires --plan-digest/);

const digestOnly = resolveReplayEntryIndex(
params({ digest: PLAN_DIGEST }),
ACTION_COUNT,
PLAN_DIGEST,
);
assert.equal(digestOnly.ok, false);
if (digestOnly.ok) throw new Error('unreachable');
assert.match(digestOnly.message, /--from requires --plan-digest/);
});

test('a valid in-range --from resolves to the 0-based entry index (from - 1)', () => {
const result = resolveReplayEntryIndex(
params({ from: 3, digest: PLAN_DIGEST }),
ACTION_COUNT,
PLAN_DIGEST,
);
assert.deepEqual(result, { ok: true, value: 2 });
});

// ---------------------------------------------------------------------------
// Rejection: out-of-range --from.
// ---------------------------------------------------------------------------

test('rejection: --from below 1 or above the plan length (with no matching empty-tail watermark) is out of range', () => {
const zero = resolveReplayEntryIndex(
params({ from: 0, digest: PLAN_DIGEST }),
ACTION_COUNT,
PLAN_DIGEST,
);
assert.equal(zero.ok, false);
if (zero.ok) throw new Error('unreachable');
assert.match(zero.message, /out of range for a 5-step plan/);

// ACTION_COUNT + 1 (6) is the one legal empty-tail boundary, but ONLY with
// a matching watermark (covered separately below) — absent one, it is out
// of range exactly like anything past it.
const pastEnd = resolveReplayEntryIndex(
params({ from: ACTION_COUNT + 2, digest: PLAN_DIGEST }),
ACTION_COUNT,
PLAN_DIGEST,
);
assert.equal(pastEnd.ok, false);
if (pastEnd.ok) throw new Error('unreachable');
assert.match(pastEnd.message, /out of range for a 5-step plan/);

const emptyTailNoWatermark = resolveReplayEntryIndex(
params({ from: ACTION_COUNT + 1, digest: PLAN_DIGEST }),
ACTION_COUNT,
PLAN_DIGEST,
);
assert.equal(emptyTailNoWatermark.ok, false);
if (emptyTailNoWatermark.ok) throw new Error('unreachable');
assert.match(emptyTailNoWatermark.message, /out of range for a 5-step plan/);
});

// Counterfactual (docs/agents/testing.md): reverting describeOutOfRangeResumeFrom's
// `from <= actionCount` bound to `from <= actionCount + 1` (dropping the
// authorization gate entirely) turns this red — verified by hand, restored
// before commit. Recorded here so the proof does not have to be re-derived:
// `git stash` a local edit changing `from <= actionCount` to
// `from <= actionCount + 1` in resume.ts, re-run this file, observe the
// "rejection: --from below 1..." case fail on its `pastEnd`/`emptyTailNoWatermark`
// assertions, then `git stash pop` to restore.

test('rejection: --plan-digest that does not match the current plan digest is stale', () => {
const result = resolveReplayEntryIndex(
params({ from: 2, digest: OTHER_DIGEST }),
ACTION_COUNT,
PLAN_DIGEST,
);
assert.equal(result.ok, false);
if (result.ok) throw new Error('unreachable');
assert.match(result.message, /does not match the current plan digest/);
});

// ---------------------------------------------------------------------------
// Empty-tail resume: the ONE ordinal beyond the plan's end (actionCount + 1),
// authorized only for the exact session/target that produced the watermark.
// ---------------------------------------------------------------------------

test('empty-tail: actionCount + 1 resolves when the watermark matches and the session has grown since the divergence', () => {
const pendingRecordAndHeal: PendingRecordAndHeal = {
expectedFrom: ACTION_COUNT + 1,
actionsCountAtDivergence: 3,
};
const result = resolveReplayEntryIndex(
params({
from: ACTION_COUNT + 1,
digest: PLAN_DIGEST,
pendingRecordAndHeal,
sessionActionsLength: 4, // grew past actionsCountAtDivergence
}),
ACTION_COUNT,
PLAN_DIGEST,
);
assert.deepEqual(result, { ok: true, value: ACTION_COUNT });
});

test('empty-tail: a watermark for a DIFFERENT --from ordinal does not authorize this one', () => {
const pendingRecordAndHeal: PendingRecordAndHeal = {
expectedFrom: ACTION_COUNT, // not ACTION_COUNT + 1
actionsCountAtDivergence: 3,
};
const result = resolveReplayEntryIndex(
params({
from: ACTION_COUNT + 1,
digest: PLAN_DIGEST,
pendingRecordAndHeal,
sessionActionsLength: 4,
}),
ACTION_COUNT,
PLAN_DIGEST,
);
assert.equal(result.ok, false);
if (result.ok) throw new Error('unreachable');
assert.match(result.message, /out of range for a 5-step plan/);
});

// ---------------------------------------------------------------------------
// Heal semantics: the watermark alone is not enough — the session's own
// recorded action count must have grown, proving the corrective press (or
// re-recorded read) actually happened in this repair segment.
// ---------------------------------------------------------------------------

test('heal: a matching watermark with NO session growth is rejected as an unperformed record-and-heal', () => {
const pendingRecordAndHeal: PendingRecordAndHeal = {
expectedFrom: ACTION_COUNT + 1,
actionsCountAtDivergence: 3,
};
const result = resolveReplayEntryIndex(
params({
from: ACTION_COUNT + 1,
digest: PLAN_DIGEST,
pendingRecordAndHeal,
sessionActionsLength: 3, // unchanged since the divergence — no corrective action recorded
}),
ACTION_COUNT,
PLAN_DIGEST,
);
assert.equal(result.ok, false);
if (result.ok) throw new Error('unreachable');
assert.match(result.message, /no corrective action was\s+recorded in this repair segment/);
assert.match(result.message, /--record/);
});

test('heal: the unperformed-record-and-heal message is scoped to the matching watermark, not a generic in-range --from', () => {
// A mid-plan --from that never matches a pending watermark's expectedFrom
// is not subject to the growth check at all — it is either accepted
// (in-range) or rejected as out-of-range, never as "unperformed".
const pendingRecordAndHeal: PendingRecordAndHeal = {
expectedFrom: ACTION_COUNT + 1,
actionsCountAtDivergence: 3,
};
const result = resolveReplayEntryIndex(
params({
from: 2,
digest: PLAN_DIGEST,
pendingRecordAndHeal,
sessionActionsLength: 3,
}),
ACTION_COUNT,
PLAN_DIGEST,
);
assert.deepEqual(result, { ok: true, value: 1 });
});
Loading
Loading