返回 DeepSeek-Reasonix
stream-interrupt-reason.test.ts
根目录 / desktop / frontend / src / __tests__ / stream-interrupt-reason.test.ts
1 // Run: tsx src/__tests__/stream-interrupt-reason.test.ts
2
3 import { initialState, reducer } from "../lib/useController";
4
5 type ReducerState = ReturnType<typeof reducer>;
6
7 let passed = 0;
8 let failed = 0;
9
10 function ok(cond: boolean, label: string, detail?: unknown) {
11 if (cond) {
12 process.stdout.write(` PASS ${label}\n`);
13 passed += 1;
14 } else {
15 process.stdout.write(` FAIL ${label}${detail === undefined ? "" : `: ${JSON.stringify(detail)}`}\n`);
16 failed += 1;
17 }
18 }
19
20 function noticeTexts(state: ReducerState): Array<{ level: string; text: string }> {
21 return state.items
22 .filter((it): it is Extract<typeof it, { kind: "notice" }> => it.kind === "notice")
23 .map((it) => ({ level: it.level, text: it.text }));
24 }
25
26 function interruptedTurnWithDiscard(reason?: string): ReducerState {
27 let state = reducer(initialState, { type: "user", text: "hello", seq: 0, submissionId: "stream-reason" });
28 state = reducer(state, { type: "event", e: { kind: "turn_started" } });
29 state = reducer(state, { type: "event", e: { kind: "stream_attempt", streamAttempt: { id: "sa-1", action: "begin", attempt: 1, max: 6 } } });
30 if (reason) {
31 state = reducer(state, { type: "event", e: { kind: "stream_attempt", streamAttempt: { id: "sa-1", action: "discard", attempt: 1, max: 6, reason } } });
32 }
33 return reducer(state, { type: "event", e: { kind: "turn_done", status: "interrupted" } });
34 }
35
36 console.log("\ninterrupted turn surfaces the last stream failure reason (#9560)");
37
38 {
39 const state = interruptedTurnWithDiscard("idle_timeout");
40 const notices = noticeTexts(state);
41 ok(notices.some((n) => n.level === "info" && n.text.includes("interrupted")), "generic interrupted notice stays");
42 const reason = notices.find((n) => n.level === "warn" && n.text.includes("idle timeout"));
43 ok(Boolean(reason), "warn notice explains the idle timeout", notices);
44 ok(notices.filter((n) => n.level === "warn").length === 1, "exactly one reason notice");
45 }
46
47 {
48 const state = interruptedTurnWithDiscard("connection_reset");
49 const notices = noticeTexts(state);
50 ok(notices.some((n) => n.level === "warn" && n.text.includes("reset")), "connection_reset maps to a readable reason", notices);
51 }
52
53 {
54 const state = interruptedTurnWithDiscard(undefined);
55 const notices = noticeTexts(state);
56 ok(!notices.some((n) => n.level === "warn" && n.text.includes("connection")), "a stop with no recorded failure adds no reason notice", notices);
57 }
58
59 {
60 // A discard from an earlier turn must not leak into a later interrupted turn.
61 let state = reducer(initialState, { type: "user", text: "one", seq: 0, submissionId: "stream-one" });
62 state = reducer(state, { type: "event", e: { kind: "turn_started" } });
63 state = reducer(state, { type: "event", e: { kind: "stream_attempt", streamAttempt: { id: "sa-1", action: "begin", attempt: 1, max: 6 } } });
64 state = reducer(state, { type: "event", e: { kind: "stream_attempt", streamAttempt: { id: "sa-1", action: "discard", attempt: 1, max: 6, reason: "idle_timeout" } } });
65 state = reducer(state, { type: "event", e: { kind: "turn_done", status: "completed" } });
66 state = reducer(state, { type: "user", text: "two", seq: 5, submissionId: "stream-two" });
67 state = reducer(state, { type: "event", e: { kind: "turn_started" } });
68 state = reducer(state, { type: "event", e: { kind: "turn_done", status: "interrupted" } });
69 const notices = noticeTexts(state).filter((n) => n.level === "warn" && n.text.includes("idle timeout"));
70 ok(notices.length === 0, "stale reason from a previous turn is cleared on turn_started", notices);
71 }
72
73 {
74 // The agent emits one safe reason before the controller closes the turn with
75 // its raw error. Desktop must keep the safe notice without adding a duplicate.
76 let state = reducer(initialState, { type: "user", text: "hello", seq: 0, submissionId: "stream-terminal" });
77 state = reducer(state, { type: "event", e: { kind: "turn_started" } });
78 state = reducer(state, { type: "event", e: {
79 kind: "notice",
80 level: "warn",
81 code: "stream_interrupted_idle_timeout",
82 text: "model stream stalled: no data arrived before the idle timeout; check the provider gateway or network proxy",
83 } });
84 state = reducer(state, { type: "event", e: { kind: "turn_done", status: "failed", err: "raw upstream transport failure" } });
85 const notices = noticeTexts(state);
86 ok(notices.filter((n) => n.level === "warn").length === 1, "terminal stream failure renders one warning", notices);
87 ok(!notices.some((n) => n.text.includes("raw upstream")), "duplicate raw turn error is suppressed", notices);
88 }
89
90 {
91 const started = reducer(initialState, { type: "event", e: { kind: "turn_started" } });
92 const recovered = reducer(started, { type: "event", e: { kind: "turn_done", status: "recovery_required", recovery: { state: "recovery_required" } } });
93 ok(!recovered.running, "recovery-required is terminal and frees the composer");
94 ok(noticeTexts(recovered).some(n => n.level === "info"), "recovery-required keeps the interrupted display notice");
95 }
96 process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
97 if (failed > 0) process.exit(1);
98
98 lines TYPESCRIPT