返回 DeepSeek-Reasonix
remote-running-reconcile.test.ts
根目录 / desktop / frontend / src / __tests__ / remote-running-reconcile.test.ts
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
97 lines TYPESCRIPT