| 1 | import assert from "node:assert/strict"; |
| 2 | import { initialState, reducer, promptEventClock } from "../lib/useController"; |
| 3 | import { findTabAfterSubmitFailure } from "../lib/turnSubmissionFailure"; |
| 4 | import type { TabMeta } from "../lib/types"; |
| 5 | |
| 6 | const idle = { type: "backend_status", running: false, pendingPrompt: false, backgroundJobs: 0, |
| 7 | cancelRequested: false, cancellable: false, runtimeEpoch: "epoch-a", turnEventSeq: 7 } as const; |
| 8 | const now = promptEventClock(); |
| 9 | const baseline = reducer(initialState, { ...idle, snapshotAt: now }); |
| 10 | const optimistic = reducer(baseline, { type: "user", text: "hello", seq: 0, submissionId: "send" }); |
| 11 | const rejected = reducer(optimistic, { type: "turn_submit_rejected", submissionId: "send", error: "not ready" }); |
| 12 | assert.equal(reducer(rejected, { ...idle, snapshotAt: now }), rejected, "pre-submit snapshot cannot undo a later lifecycle event"); |
| 13 | const fresh = promptEventClock() + 1; |
| 14 | const settled = reducer(rejected, { ...idle, snapshotAt: fresh }); |
| 15 | assert.equal(settled.running, false, "same-sequence fresh idle clears a rejected optimistic send"); |
| 16 | assert.equal(settled.cancellable, false); |
| 17 | assert.equal(reducer(settled, { ...idle, running: true, cancellable: true, snapshotAt: now }), settled, "out-of-order equal-sequence response cannot restore running"); |
| 18 | assert.equal(reducer(settled, { ...idle, running: true, cancellable: true, snapshotAt: fresh }), settled, "duplicate snapshot is ignored"); |
| 19 | assert.equal(reducer(settled, { ...idle, running: true, cancellable: true }), settled, "equal sequence without freshness cannot override state"); |
| 20 | assert.equal(reducer(settled, { ...idle, running: true, cancellable: true, turnEventSeq: 6, snapshotAt: fresh + 1 }), settled, "older backend sequence is always stale"); |
| 21 | const active = reducer(settled, { ...idle, running: true, cancellable: true, turnEventSeq: 8, snapshotAt: fresh + 2 }); |
| 22 | assert.equal(active.running, true, "newer backend event still advances state"); |
| 23 | const replaced = reducer(active, { ...idle, runtimeEpoch: "epoch-b", turnEventSeq: 1, snapshotAt: fresh + 3 }); |
| 24 | assert.equal(replaced.running, false, "new runtime may restart its event sequence"); |
| 25 | |
| 26 | let clock = 100; |
| 27 | let finishRead: (tabs: TabMeta[]) => void = () => {}; |
| 28 | const read = findTabAfterSubmitFailure({ ListTabs: () => new Promise((resolve) => { finishRead = resolve; }) }, "tab", [0], () => clock); |
| 29 | // A newer lifecycle event arrives while the older status read is in flight. |
| 30 | clock = 200; |
| 31 | finishRead([{ id: "tab", running: false }] as TabMeta[]); |
| 32 | const snapshot = await read; |
| 33 | assert.ok(snapshot && snapshot[1] < 150, "reconciliation carries read-start time, not response time"); |
| 34 | const newerTurn = { ...rejected, turnLifecycleObservedAt: 150 }; |
| 35 | assert.equal(reducer(newerTurn, { ...idle, snapshotAt: snapshot[1] }), newerTurn, "delayed failed-send reconciliation cannot clear a newer turn"); |
| 36 | console.log(" PASS runtime snapshots use event order and read freshness without trapping optimistic state"); |
| 37 |