// Copyright 2026 TesterArmy. // Modified by Dario Kondratiuk. // SPDX-License-Identifier: Apache-2.0 using System.Globalization; using System.Text.Json; using E2E.Cache; using E2E.Engine; using E2E.Internal; using static E2E.Agent; namespace E2E; /// /// Upstream's StepOutcome: how a step settled, as the write-side decision sees it. is a step /// that ended without anything judging the app: it was cancelled, or no model answered. Neither says the recorded flow is /// wrong, so neither implicates its entry. /// internal enum StepOutcome { Passed, Failed, NoVerdict, } /// /// Upstream's StepCacheHost (agent/step-cache.ts): the replay host plus what the session reads off the step's /// screens. has no upstream counterpart. The port reads a recording's delta from the screen the /// step's first action was resolved against, where upstream reads it from the first screen on the route the step ended on. /// internal interface IStepCacheHost : IReplayHost { /// The screen the step's first committed action was resolved against, if it took one. Observation? FirstActedOn { get; } } /// Upstream's ClaimedKey: the key a step claimed and the step it names, which an entry records as its provenance. internal sealed record ClaimedKey(string Key, CacheEntry Step); /// /// Upstream's StepCacheOptions, with the cache context (AgentCacheContext) it holds folded in: the store, the /// mode, and the claimed key. The port has no params templates, redaction functions, or executor to pass: its /// act reads params as they are, redacts through the host, and records the agent's configured model, not the executor. /// internal sealed class StepCacheOptions { public required IStepCache Cache { get; init; } /// False in read-only mode: the step replays but does not write or delete recordings. public bool Write { get; init; } /// Upstream's cache.strict. public bool Strict { get; init; } /// Whether this attempt may replay: a first attempt, or any attempt under strict mode. public bool ReplayEligible { get; init; } public required ClaimedKey Claim { get; init; } /// The step's index among the attempt's steps. Null when no step is running. public int? StepIndex { get; init; } public required string Instruction { get; init; } public IReadOnlyDictionary? Params { get; init; } /// The step's recorder, which the step's budget also holds. public required TraceRecorder Recorder { get; init; } /// /// The origin the app is served on. .NET-only: upstream maps a screen to its path in the host's observe /// (screenOf, agent/act.ts); the port's host returns the observation, so the session maps it. /// public string? AppOrigin { get; init; } } /// /// Upstream's StepTraceSession (agent/step-cache.ts): one step's trace-cache session, the replay attempt, live /// recording, and the stage-or-evict decision once the step has settled. The dispatch owns budgets, the grammar, and the /// verdict; this class owns nothing but the cache. The recording is staged as a , which the /// attempt settles at its end (upstream's settleStagedTraces, here E2ESession.Flush). /// internal sealed class StepTraceSession { // Quotes a label in a detail as JSON.stringify does: text stays readable, not escaped to \u codes. private readonly IStepCacheHost _host; private readonly StepCacheOptions _options; private readonly TraceRecorder _recorder; private CacheInfo? _info; private string? _recordedBy; private bool _changedNothing; private ReplayedPrefix? _prefix; private Observation? _start; private CacheEntry? _wholeReplay; private int _actionsAtEndMismatch = -1; private long _startedMs; private long? _handedOffMs; private long? _executorCompletedMs; // Upstream's END_WAIT_MARGIN_MS: room for a slower day on top of how long the app took to show its end state. private const int EndWaitMarginMs = 10_000; public StepTraceSession(IStepCacheHost host, StepCacheOptions options) { _host = host; _options = options; _recorder = options.Recorder; Pending = new PendingAct { Key = options.Claim.Key, ParamCollision = CacheKeys.Collides(options.Params), Step = options.Claim.Step, StepIndex = options.StepIndex, }; } /// The act as the attempt settles it: upstream's staged entry. public PendingAct Pending { get; } /// The screen the step began on, if it could be read. The executor's first look can read it. public Observation? Start => _start; /// How the cache participated, for the step's report detail. public CacheInfo? CacheInfo => _info is null || _recordedBy is null ? _info : _info.WithRecordedBy(_recordedBy); /// The mid-step hand-off, when a replay diverged after real progress. public ReplayedPrefix? ReplayedPrefix => _prefix; /// Upstream's failedStepOutcome: the outcome of a step that threw . public static StepOutcome FailedStepOutcome(Exception? cause) => cause is AgentException { Code: "CANCELLED" or "MODEL_UNAVAILABLE" or "MODEL_PROVIDER_FAILED" or "REPLAY_STALE" } or AgentException { FromActionHook: true } ? StepOutcome.NoVerdict : StepOutcome.Failed; private const string ReplayNoticeStart = "replayed "; private const string RecordedVerdictSeparator = "; recorded verdict: "; /// /// Upstream's recordedVerdictOf: the recorded verdict alone, for the ledger later steps read. How the step was served is /// the report's business, not context a model should reason about. A summary that is not a replay notice is returned as it is. /// public static string RecordedVerdictOf(string summary) { if (!summary.StartsWith(ReplayNoticeStart, StringComparison.Ordinal)) { return summary; } var at = summary.IndexOf(RecordedVerdictSeparator, StringComparison.Ordinal); return at == -1 ? summary : summary[(at + RecordedVerdictSeparator.Length)..]; } /// Records one committed grammar action into the step trace. public void Record(RecordedAction action) => _recorder.Record(action); /// /// Opens the step: reads the cache entry, captures the start screen, then attempts a zero-turn replay of a hit. Returns /// the self-finalized verdict on a full replay whose postcondition holds; null dispatches the executor, after a miss /// from the top, or after a divergence mid-step with set. Every failure to replay is a /// miss, never an error; only runtime hard stops propagate, and under strict mode a stale recording. /// public async Task BeginAsync() { // Outside strict mode, a retry records like any step but never replays; the report says so instead of looking // like a step that ran with caching off. // A screen that cannot be read is the executor's business: the step runs, nothing is replayed or recorded. _startedMs = NowMs(); _start = await ProbeAsync(SettleMode.HeldStill).ConfigureAwait(false); if (_start is null) { return null; } var read = _options.ReplayEligible ? ReadEntry() : new EntryRead(null, "retry"); if (read.Entry is null) { _info = Missed(read.Reason!, 0); if (!Pending.ReadFailed) { FailIfStale(); } if (string.Equals(read.Reason, "no-entry", StringComparison.Ordinal)) { FailIfRekeyed(); } return null; } var verdict = await ReplayEntryAsync(read.Entry).ConfigureAwait(false); if (verdict is null) { FailIfStale(); } return verdict; } /// /// Closes the session once the step has settled. In read-write mode a passed step stages its recording for the attempt's /// settlement: the entry it replayed whole, to keep, or the trace it recorded. A passed step that an end-mismatch hand-off /// had to repair evicts the entry: every recorded action ran and the effect was still missing, so the recorded flow is /// proven not to produce it. A step that failed or had no verdict stages nothing; the attempt's settlement evicts what a /// failed step consumed, and spares it when nothing judged the app. /// public async Task ConcludeAsync(StepOutcome outcome, string? recordedBy = null) { if (_handedOffMs is not null) { _executorCompletedMs = NowMs(); } if (outcome != StepOutcome.Passed) { return; } if (_wholeReplay is not null) { Pending.Completed = true; Pending.ReplayedWhole = true; Pending.Entry = _wholeReplay; return; } if (_actionsAtEndMismatch >= 0 && _recorder.RecordedCount > _actionsAtEndMismatch) { if (_options.Write) { // A store that throws did not delete the entry, so nothing is reported as evicted. try { _options.Cache.Delete(Pending.Key); Pending.Decided = CacheWrite.Evicted; } catch (Exception ex) when (ex is not OperationCanceledException) { // The cache is disposable: the settlement decides what becomes of the entry. } } return; } Pending.Completed = true; // Only a cache that writes keeps the recording, so only it waits for the screen to settle: after the last action's // change, held still. A step that took no action has nothing to record. Pending.Entry = _options.Write && _start is not null && _recorder.RecordedCount > 0 ? await StageAsync(CacheEntry.ReadRecordedBy(recordedBy)).ConfigureAwait(false) : null; if (_changedNothing && !Pending.ReadEntry) { // A step that read an entry and recorded nothing evicts it at the settlement instead. Pending.Decided = CacheWrite.NoChange; } if (_info is not null && Pending.ParamCollision && Pending.Entry is not null) { // The recording cannot be templated safely, so it is not written, and the result says why. _info = new CacheInfo { Mode = _info.Mode, Reason = _info.Reason, ReplayedActions = _info.ReplayedActions, TotalActions = _info.TotalActions, Detail = _info.Detail, NotRecorded = "param-collision", Entry = _info.Entry }; } } // A store that cannot be read is a miss, never a failed step: the cache is disposable. private EntryRead ReadEntry() { CacheLookup lookup; try { lookup = _options.Cache.Read(Pending.Key); } catch (Exception ex) when (ex is not OperationCanceledException) { Pending.ReadFailed = true; return new EntryRead(null, "invalid-entry"); } if (lookup.Entry is not { } entry) { return new EntryRead(null, lookup.Reason ?? "no-entry"); } Pending.ReadEntry = true; // Any store can return a hit, so its shape is checked here, at the one read site. return entry.Schema != FileStepCache.SchemaVersion || entry.Actions is not { Count: > 0 } || entry.Appeared is null || entry.Gone is null || !entry.HasValidEndWait() || !entry.Actions.All(action => action.HasValidModifiers() && action.HasValidSpans()) ? new EntryRead(null, "invalid-entry") : new EntryRead(entry, null); } // Under strict mode, ends a step whose recording exists but no longer replays, rather than handing it to the executor: a // committed recording that went stale is a change to re-record in review, not a model call to absorb on every run. // A recording that is absent, or that stops at a project tool, is no stale recording. private void FailIfStale() { var reason = _info?.Reason; if (!_options.Strict || reason is null or "no-entry" or "gap") { return; } throw new AgentException( "REPLAY_STALE", "The recording for '" + _options.Instruction + "' no longer matches (" + reason + "). Strict cache mode does not run it live; re-record it without cache.strict."); } // Under strict mode, ends a step whose key found no entry while the cache directory holds a recording made for the same // step under another key: the runner, the engine, the app, or the agent's context changed since, and the recording no longer replays. A step // whose instruction or params changed is a new step and still runs live. A custom IStepCache is not checked. private void FailIfRekeyed() { if (!_options.Strict || _options.Cache is not FileStepCache files || files.UnderAnotherKey(Pending.Key, Pending.Step!) is not { } previous) { return; } var changes = Pending.Step!.KeyedBy is { } now ? Identity.KeyContextChanges(previous.KeyedBy, now) : []; var why = changes.Count == 0 ? "the runner, the engine, the app, or the agent's context changed" : string.Join(", ", changes) + " changed"; throw new AgentException( "REPLAY_STALE", "The recording for '" + _options.Instruction + "' sits under another cache key (" + previous.Key + ".json), since " + why + " after it was recorded. Strict cache mode does not run it live; re-record it without cache.strict."); } // Replays one hit: decides it against the start route, runs the recorded actions, then checks the postcondition. Actions // that all ran prove the clicks happened; only the postcondition proves the save took. A flow whose destination changed, // or whose effect is not on screen again, hands off like any other divergence. private async Task ReplayEntryAsync(CacheEntry entry) { Pending.RecordedActions = entry.Actions.Count; _recordedBy = CacheEntry.ReadRecordedBy(entry.RecordedBy); if (Decide.DecideTraceReplay(entry, _start is null ? null : ScreenPath(_start), _options.Params) is { Reason: { } missReason }) { _info = Missed(missReason, entry.Actions.Count); return null; } Pending.ConsumedReplay = true; // Every screen the replay looks at, in order, from the start: the end check measures the recorded delta from the first // of them on the route the replay ends on. var screens = new List(); if (_start is not null) { screens.Add(_start); } var endRoute = Recorded(entry.EndRoute, _options.Params); var watched = new WatchedHost(_host, screens); var outcome = await Replay.ReplayTrace( watched, entry.Actions, new ReplayOptions { Initial = _start, LooksBeforeFree = () => screens.Count == 0 || !CacheRoute.Same(ScreenPath(screens[^1]), endRoute) }).ConfigureAwait(false); // Where the replay stopped, for the report: the recorded action, one-based, and what went wrong there when // more than the reason says. string StoppedAt(string? why = null) => "at action " + (_recorder.RecordedCount + 1).ToString(CultureInfo.InvariantCulture) + " of " + entry.Actions.Count.ToString(CultureInfo.InvariantCulture) + ", " + DefaultAgent.Describe(entry.Actions[_recorder.RecordedCount]) + (why is null ? "" : ": " + why); if (!outcome.Completed) { var reason = ReplayHandOffReasons.Name(outcome.StopReason!.Value); switch (outcome.StopReason) { case ReplayHandOffReason.Gap or ReplayHandOffReason.TargetNotFound or ReplayHandOffReason.TargetAmbiguous or ReplayHandOffReason.InvalidEntry when outcome.Executed == 0: _recorder.Clear(); _info = Missed(reason, entry.Actions.Count, StoppedAt()); return null; case ReplayHandOffReason.ActionUncertain: HandOff(reason, StoppedAt(outcome.Detail), outcome.UncertainAction); return null; case ReplayHandOffReason.ActionFailed: HandOff(reason, StoppedAt(outcome.Detail)); return null; default: HandOff(reason, StoppedAt()); return null; } } var end = await WaitForEndAsync(entry, screens).ConfigureAwait(false); if (!end.Matches) { HandOff("end-mismatch", StopDetail(entry, end)); return null; } _wholeReplay = entry; _info = new CacheInfo { Mode = "self-finalized", Entry = Pending.Key, ReplayedActions = _recorder.RecordedCount, TotalActions = entry.Actions.Count }; return new StepVerdict(StepVerdictStatus.Passed, "Replayed recorded actions."); } private long NowMs() => _host.Time.GetUtcNow().ToUnixTimeMilliseconds(); // Upstream's endWaitBaselineMs: the app-settling baseline, the executor's verdict or, if it acted, its last action. The // replay's and the executor's own turns are not app settling time. private long EndWaitBaselineMs() { var lastActionAtMs = _recorder.LastActionAtMs ?? _startedMs; return _handedOffMs is null || (_recorder.LastActionAtMs ?? 0) > _handedOffMs ? lastActionAtMs : _executorCompletedMs ?? _handedOffMs.Value; } private void HandOff(string reason, string? detail, string? uncertain = null) { _handedOffMs = NowMs(); if (string.Equals(reason, "end-mismatch", StringComparison.Ordinal)) { _actionsAtEndMismatch = _recorder.RecordedCount; } _prefix = new ReplayedPrefix(_recorder.Actions.Select(DefaultAgent.Describe).ToList(), Pending.RecordedActions, ReplayHandOffReasons.Parse(reason), uncertain); _info = new CacheInfo { Mode = "agent-concluded", Entry = Pending.Key, Reason = reason, ReplayedActions = _recorder.RecordedCount, TotalActions = Pending.RecordedActions, Detail = detail }; } private CacheInfo Missed(string reason, int totalActions, string? detail = null) => new() { Mode = "missed", Entry = Pending.Key, Reason = reason, ReplayedActions = _recorder.RecordedCount, TotalActions = totalActions, Detail = detail }; // The last action may start a navigation or a slow render, so the end route and anchors get the replay timeout to show up. // The replay must also have produced the delta: an outcome already on screen before the actions proves nothing. // A failure says which of these failed, for the report: an outcome that already showed, or the anchors that // did not hold on the last look the wait took on the recorded end route. private async Task WaitForEndAsync(CacheEntry entry, List screens) { var parameters = _options.Params; // As a recording reads its delta, the evidence counts from the screen the first action was resolved against. // A screen a scroll to a text marked while it paged is not among the screens; the stretch then opens with it. var actedOn = _host.FirstActedOn ?? _start; var from = actedOn is null ? -1 : screens.IndexOf(actedOn); List stretch = from >= 0 ? screens.GetRange(from, screens.Count - from) : actedOn is null ? screens : [actedOn, .. screens.Skip(1)]; var baseline = Baseline(stretch, Recorded(entry.EndRoute, parameters)); var baselineAnchors = baseline is null ? null : Project(baseline); var inputTargets = entry.Actions.Where(HasTarget).Select(action => new RecordedTarget { Role = action.Role, Name = action.Name, TestId = action.TestId }); if (!Anchors.Evidenced(entry, baselineAnchors, inputTargets)) { // A recording with no delta fails the evidence too, but nothing of it showed before the replay // acted: that is the generic detail. return entry.Appeared.Count > 0 || entry.Gone.Count > 0 ? new EndStateCheck(false, AlreadyShowing: true) : new EndStateCheck(false); } var before = baselineAnchors ?? (actedOn is null ? [] : Project(actedOn)); var endRoute = Recorded(entry.EndRoute, parameters); // The last action may start a navigation, so the end route gets the replay timeout to show up. A route that never matches is // another screen, whatever it shows. var arrived = await EndScreenAsync(endRoute).ConfigureAwait(false); if (arrived is null) { return new EndStateCheck(false); } // Every look the wait takes must still be on the recorded end route: a screen that moved on after the route first matched // is another screen. DeltaMismatch? mismatch = null; bool Holds(Observation screen) { if (!CacheRoute.Same(endRoute, screen.Route)) { return false; } mismatch = Anchors.Mismatch(entry, Project(screen), before); return mismatch is null; } var matches = await Replay.VerifyEndState(_host, Holds, new VerifyEndStateOptions { WaitMs = entry.EndWaitMs, Initial = arrived }).ConfigureAwait(false); return matches ? new EndStateCheck(true) : new EndStateCheck(false, Failure: mismatch); } // Upstream's endScreen: captures the end state once its route matches the recording, polling while a navigation the last // action started commits. Null when the route never matches within the replay timeout. private async Task EndScreenAsync(string? endRoute) { var deadline = _host.Time.GetUtcNow() + _host.ReplayTimeout; while (true) { // Upstream's probeScreen: a surface that cannot be observed right now is an end-state miss, not a failure. var observation = await ProbeAsync(SettleMode.Raw).ConfigureAwait(false); if (observation is null) { return null; } if (CacheRoute.Same(endRoute, ScreenPath(observation))) { return observation; } // Upstream also stops once the step clock cannot cover another delay, so the miss hands off with time left. if (_host.Time.GetUtcNow() >= deadline || _host.RemainingMs() <= 20) { return null; } await Task.Delay(TimeSpan.FromMilliseconds(20), _host.Time, _host.Signal).ConfigureAwait(false); } } // The cache is disposable: a passing step whose end screen cannot be read records nothing, and still passes. private async Task StageAsync(string? recordedBy) { var end = await ProbeAsync(SettleMode.HeldStill).ConfigureAwait(false); return end is null ? null : BuildEntry(_host.FirstActedOn ?? _start!, end, recordedBy); } // A step that changed nothing a replay could check, no node and no route, records nothing: // its recording would replay on mechanics alone. private CacheEntry? BuildEntry(Observation baseline, Observation end, string? recordedBy) { var routeMoved = !CacheRoute.Same(ScreenPath(baseline), ScreenPath(end)); var (appeared, gone) = Anchors.Describe(Project(baseline), Project(end), routeMoved); if (appeared.Count == 0 && gone.Count == 0 && !routeMoved) { _changedNothing = true; return null; } // How long the app took to show its end state after the last action, plus room for a slower day: the budget a replay waits // for the recorded end state to return. Measured from the last action, not the step's start: the model's thinking time before // that action is no reason for a replay, which does not think, to wait. return _recorder.Finalize(Pending.Step!, Template(ScreenPath(_start!)), Template(ScreenPath(end)), appeared, gone, recordedBy, NowMs() - EndWaitBaselineMs() + EndWaitMarginMs); } // One cache capture, settled as far as mode asks, or null when the surface cannot be observed right now, which is the // executor's business, not the cache's. Runtime hard stops (timeout, cancellation) are the step's truth even when they // land during cache bookkeeping, and propagate. private async Task ProbeAsync(SettleMode mode) { try { return await _host.ObserveAsync(mode).ConfigureAwait(false); } catch (EngineException ex) when (!StepExecutors.IsRuntimeHardStop(ex) && !_host.Signal.IsCancellationRequested) { return null; } } // Upstream's ObservedScreen.path: where the screen is, as the cache compares it (CacheRoute.AppLocation). private string ScreenPath(Observation observation) => CacheRoute.AppLocation(observation.Route, _options.AppOrigin); // A route is recorded redacted, with each unique() value as its slot, and read back with this call's values. private string Template(string route) => CacheKeys.Template(_host.Redact.Redact(route), _options.Params); private static string? Recorded(string? route, IReadOnlyDictionary? parameters) => route is null ? null : CacheKeys.Detemplate(route, parameters); // Anchors as the recording stores them: a name or text holding a unique param is skipped, every field has // secrets redacted, and a name, text, or value has each unique param as its slot. The secrets are read as // they are now, so an unchanged node is no delta. private List Project(Observation observation) { var redactor = _host.Redact; var parameters = _options.Params; return Anchors.Project( observation, text => ContainsParam(text, parameters), value => CacheKeys.Template(redactor.Redact(value), parameters), redactor.Redact, (text, limit) => AgentObservation.RedactText(text, redactor, limit)); } private static bool ContainsParam(string name, IReadOnlyDictionary? parameters) { if (parameters is null) { return false; } foreach (var value in parameters.Values) { if (value is UniqueValue unique && unique.Value.Length > 0 && name.Contains(unique.Value, StringComparison.Ordinal)) { return true; } } return false; } // The first screen of the replay's last stretch on the end route, or null when its last screen before the end // was on another route, so the last action moved it. private Observation? Baseline(List screens, string? endRoute) { Observation? baseline = null; foreach (var screen in screens) { baseline = CacheRoute.Same(ScreenPath(screen), endRoute) ? baseline ?? screen : null; } return baseline; } // Where and why a replay handed the step back, for the report: for an end state that did not hold, why: the // outcome already showed before the replay acted, or the anchors that failed on the last look. With neither // known, the screen was never read on the recorded route, and the detail says what the recording expected there. private string StopDetail(CacheEntry entry, EndStateCheck end) { var route = Recorded(entry.EndRoute, _options.Params); var where = string.IsNullOrEmpty(route) ? "" : " on " + route; if (end.AlreadyShowing) { return "every recorded action ran, but the recorded outcome already showed" + where + " before the replay acted there, so nothing proves the replay produced it"; } var what = end.Failure is { } failure ? MismatchParts(failure) : entry.Appeared.Count > 0 ? "expected " + string.Join(", ", entry.Appeared.Take(2).Select(DescribeAnchor)) : ""; return "every recorded action ran, but the recorded end state did not show" + where + (what.Length == 0 ? "" : ": " + what); } // A recorded anchor as the detail names it: the node, then its text when the name alone would hide the state // the recording expects (status "Marker" text="saved"). private static string DescribeAnchor(RecordedTarget anchor) { return anchor.Text is not null && anchor.Name is not null && anchor.Text != anchor.Name ? RecordedTarget.TargetLabel(anchor) + " text=" + Quote(TextRules.Bound(anchor.Text, 40)) : RecordedTarget.TargetLabel(anchor); } private static string Quote(string text) => LocatorExpect.Quote(text); // The anchors a mismatch names, two of each kind at most: those the replay did not bring up, those it did not // clear, and alerts it raised that the recording never saw. Kinds are joined with commas, never semicolons: a // report line already separates its own parts with those. private static string MismatchParts(DeltaMismatch mismatch) { static IEnumerable Part(string label, IReadOnlyList anchors) { if (anchors.Count == 0) { yield break; } var more = anchors.Count > 2 ? " (+" + (anchors.Count - 2).ToString(CultureInfo.InvariantCulture) + " more)" : ""; yield return label + " " + string.Join(" and ", anchors.Take(2).Select(DescribeAnchor)) + more; } return string.Join(", ", Part("missing", mismatch.Missing).Concat(Part("still showing", mismatch.Present)).Concat(Part("unrecorded", mismatch.UnrecordedAlerts))); } /// One store read: an entry, or why none was read. private readonly record struct EntryRead(CacheEntry? Entry, string? Reason); /// /// The replay host, watched: every look the replay takes is kept, in order, for the end check. The port's replay also needs /// the host's action slots and recording, which pass straight through. /// private sealed class WatchedHost(IStepCacheHost inner, List screens) : IReplayHost { public IExecutorActions Actions => inner.Actions; public CancellationToken Signal => inner.Signal; public Redactor Redact => inner.Redact; public TimeSpan ReplayTimeout => inner.ReplayTimeout; public int RemainingMs() => inner.RemainingMs(); public TimeProvider Time => inner.Time; public async Task ObserveAsync(SettleMode mode) { var screen = await inner.ObserveAsync(mode).ConfigureAwait(false); screens.Add(screen); return screen; } public bool TryReserve() => inner.TryReserve(); public void MarkActing() => inner.MarkActing(); public Task CommitAsync(RecordedAction action) => inner.CommitAsync(action); } } /// /// The end check's verdict and, when it is known, why it failed: the recorded outcome already showed before the /// replay acted, or the anchors that did not hold. /// internal readonly record struct EndStateCheck(bool Matches, bool AlreadyShowing = false, DeltaMismatch? Failure = null);