| 1 | // Run: tsx src/__tests__/remote-running-reconcile.test.ts |
| 2 | // |
| 3 | // The remote session reuses this reducer; these cases pin the reconciliation |
| 4 | // contract that keeps the remote pill from spinning forever: |
| 5 | // - a failed submit rolls the optimistic running flag back (send_failed) |
| 6 | // - losing the serve connection mid-turn stops the pill (turn_interrupted) |
| 7 | // - provider_unreachable surfaces as one self-replacing notice and clears |
| 8 | // on the next turn |
| 9 | |
| 10 | import { initialState, reducer } from "../lib/useController"; |
| 11 | import type { WireEvent } from "../lib/types"; |
| 12 | |
| 13 | let passed = 0; |
| 14 | let failed = 0; |
| 15 | |
| 16 | function eq(a: unknown, b: unknown, label: string) { |
| 17 | if (a === b) { |
| 18 | process.stdout.write(` PASS ${label}\n`); |
| 19 | passed += 1; |
| 20 | } else { |
| 21 | process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`); |
| 22 | failed += 1; |
| 23 | } |
| 24 | } |
| 25 | |
| 26 | const submit = { type: "user" as const, text: "你好", seq: 1, submissionId: "remote-1" }; |
| 27 | const event = (e: Partial<WireEvent> & { kind: WireEvent["kind"] }) => |
| 28 | reducer(initialState, { type: "event", e: e as WireEvent }); |
| 29 | |
| 30 | // --- a failed submit rolls running back and marks the bubble failed --- |
| 31 | { |
| 32 | const started = reducer(initialState, submit); |
| 33 | eq(started.running, true, "optimistic submit sets running"); |
| 34 | const rolled = reducer(started, { type: "send_failed", submissionId: "remote-1", error: "Send failed: refused" }); |
| 35 | eq(rolled.running, false, "send_failed clears running"); |
| 36 | eq(rolled.turnActive, false, "send_failed clears turnActive"); |
| 37 | eq(rolled.localSubmissions["remote-1"]?.status, "failed", "send_failed marks the bubble failed"); |
| 38 | eq(rolled.items.some((it) => it.kind === "notice"), true, "send_failed adds a notice"); |
| 39 | // A stale send_failed for another submission must not clobber a live turn. |
| 40 | const live = reducer(started, { type: "event", e: { kind: "turn_started" } as WireEvent }); |
| 41 | const untouched = reducer(live, { type: "send_failed", submissionId: "remote-999", error: "x" }); |
| 42 | eq(untouched.running, true, "stale send_failed leaves a live turn running"); |
| 43 | } |
| 44 | |
| 45 | // --- losing the connection mid-turn stops the pill --- |
| 46 | { |
| 47 | const running = event({ kind: "turn_started" }); |
| 48 | eq(running.running, true, "turn_started sets running"); |
| 49 | const interrupted = reducer(running, { type: "turn_interrupted" }); |
| 50 | eq(interrupted.running, false, "turn_interrupted clears running"); |
| 51 | eq(interrupted.turnActive, false, "turn_interrupted clears turnActive"); |
| 52 | eq(interrupted.cancellable, false, "turn_interrupted clears cancellable"); |
| 53 | eq(interrupted.items.some((it) => it.kind === "notice"), true, "turn_interrupted adds a notice"); |
| 54 | // Idempotent on an idle transcript: no phantom notice. |
| 55 | const idle = reducer(initialState, { type: "turn_interrupted" }); |
| 56 | eq(idle.items.length, initialState.items.length, "turn_interrupted is a no-op when idle"); |
| 57 | } |
| 58 | |
| 59 | // --- provider_unreachable: one self-replacing notice, cleared next turn --- |
| 60 | { |
| 61 | const running = event({ kind: "turn_started" }); |
| 62 | const first = event({ kind: "provider_unreachable", text: "dial tcp 127.0.0.1:38211: connect: connection refused" }) as typeof running; |
| 63 | const withNotice = reducer(running, { type: "event", e: { kind: "provider_unreachable", text: "dial tcp 127.0.0.1:38211: connect: connection refused", retryAttempt: 1, retryMax: 10 } as WireEvent }); |
| 64 | const count = withNotice.items.filter((it) => it.id === "provider-unreachable").length; |
| 65 | eq(count, 1, "provider_unreachable adds exactly one notice"); |
| 66 | const again = reducer(withNotice, { type: "event", e: { kind: "provider_unreachable", text: "still down", retryAttempt: 2, retryMax: 10 } as WireEvent }); |
| 67 | eq(again.items.filter((it) => it.id === "provider-unreachable").length, 1, "repeat provider_unreachable replaces the notice"); |
| 68 | const nextTurn = reducer(again, { type: "event", e: { kind: "turn_started" } as WireEvent }); |
| 69 | eq(nextTurn.items.some((it) => it.id === "provider-unreachable"), false, "next turn clears the channel notice"); |
| 70 | void first; |
| 71 | } |
| 72 | |
| 73 | // --- remote /status reconciliation maps onto backend_status --- |
| 74 | { |
| 75 | const running = event({ kind: "turn_started" }); |
| 76 | const idleNow = reducer(running, { |
| 77 | type: "backend_status", |
| 78 | running: false, |
| 79 | cancellable: false, |
| 80 | snapshotAt: Date.now(), |
| 81 | }); |
| 82 | eq(idleNow.running, false, "idle /status snapshot clears running"); |
| 83 | eq(idleNow.turnActive, false, "idle /status snapshot clears turnActive"); |
| 84 | const busyAgain = reducer(idleNow, { |
| 85 | type: "backend_status", |
| 86 | running: true, |
| 87 | snapshotAt: Date.now(), |
| 88 | }); |
| 89 | eq(busyAgain.running, true, "running /status snapshot restores running"); |
| 90 | } |
| 91 | |
| 92 | if (failed > 0) { |
| 93 | process.stdout.write(`\n${failed} FAILED, ${passed} passed\n`); |
| 94 | process.exit(1); |
| 95 | } |
| 96 | process.stdout.write(`\n${passed} passed, 0 failed\n`); |
| 97 |