返回 DeepSeek-Reasonix
transcript-grouping.test.ts
根目录 / desktop / frontend / src / __tests__ / transcript-grouping.test.ts
1 // Run: tsx src/__tests__/transcript-grouping.test.ts
2
3 import { readFileSync } from "node:fs";
4 import { fileURLToPath } from "node:url";
5 import { dirname, resolve } from "node:path";
6 import { buildStepGroups, buildTurnGroups, createWarmLayerState, lastQuestionTurn, questionTurnsById, warmColdPageForTurn, warmLayerForSession, warmLayerWithColdPageAtLeast, warmLayerWithExpandedTurn, warmLayerWithNextColdPage, warmPagination } from "../lib/transcriptGrouping";
7 import type { Item } from "../lib/useController";
8
9 let passed = 0;
10 let failed = 0;
11
12 function ok(cond: boolean, label: string) {
13 if (cond) {
14 process.stdout.write(` PASS ${label}\n`);
15 passed += 1;
16 } else {
17 process.stdout.write(` FAIL ${label}\n`);
18 failed += 1;
19 }
20 }
21
22 function eq<T>(actual: T, expected: T, label: string) {
23 if (actual === expected) {
24 process.stdout.write(` PASS ${label}\n`);
25 passed += 1;
26 } else {
27 process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}\n`);
28 failed += 1;
29 }
30 }
31
32 function syntheticTranscriptItems(turns: number, toolsPerTurn: number): Item[] {
33 const items: Item[] = [];
34 let seq = 0;
35 for (let turn = 0; turn < turns; turn += 1) {
36 items.push({ kind: "user", id: `u${seq++}`, text: `prompt ${turn}` });
37 items.push({ kind: "assistant", id: `a${seq++}`, text: `answer ${turn}`, reasoning: "", streaming: false });
38 for (let tool = 0; tool < toolsPerTurn; tool += 1) {
39 items.push({
40 kind: "tool",
41 id: `t${seq++}`,
42 name: "bash",
43 args: "",
44 readOnly: false,
45 status: "done",
46 dataArchived: true,
47 });
48 }
49 }
50 return items;
51 }
52
53 console.log("\ntranscript grouping contract");
54
55 {
56 const here = dirname(fileURLToPath(import.meta.url));
57 const groupingPath = resolve(here, "../lib/transcriptGrouping.ts");
58 const source = readFileSync(groupingPath, "utf8");
59 ok(!source.includes(".findIndex("), "turn grouping does not scan a second collection for each item");
60 }
61
62 {
63 const groups = buildTurnGroups(syntheticTranscriptItems(3, 2));
64 eq(groups.length, 3, "creates one group per user turn");
65 eq(groups[0].startIdx, 0, "first group start index");
66 eq(groups[0].endIdx, 4, "first group end index");
67 eq(groups[0].toolCount, 2, "counts top-level tools in a turn");
68 eq(groups[2].assistantPreview, "answer 2", "keeps latest assistant preview for each turn");
69 }
70
71 {
72 const groups = buildStepGroups([
73 { kind: "user", id: "u0", text: "fix this" },
74 { kind: "assistant", id: "a1", text: "visible answer before retry", reasoning: "", streaming: false },
75 { kind: "notice", id: "s1", level: "info", text: "↪ steer" },
76 { kind: "assistant", id: "a2", text: "", reasoning: "", streaming: true },
77 ] as Item[]);
78 eq(groups[1]?.isFinal, true, "visible assistant text before a later assistant stays outside processed folds");
79 }
80
81 {
82 const groups = buildStepGroups([
83 { kind: "user", id: "u0", text: "use tools" },
84 { kind: "assistant", id: "a1", text: "", reasoning: "", streaming: false },
85 { kind: "tool", id: "t1", name: "read_file", args: "{}", readOnly: true, status: "done" },
86 { kind: "assistant", id: "a2", text: "final answer", reasoning: "", streaming: false },
87 ] as Item[]);
88 eq(groups[1]?.isFinal, false, "tool-only completed steps still fold in compact mode");
89 eq(groups[2]?.isFinal, true, "later visible final answer renders directly");
90 }
91
92 {
93 const visibleTurns = questionTurnsById([
94 { id: "u0", text: "first", turn: 0 },
95 { id: "u1", text: "second", turn: 1 },
96 ]);
97 eq(visibleTurns.get("u0"), 0, "falls back to visible ordinal when no checkpoint turns exist");
98 eq(visibleTurns.get("u1"), 1, "visible ordinal fallback increments by question");
99
100 const backendTurns = questionTurnsById([
101 { id: "u0", text: "first", turn: 0, checkpointTurn: 0 },
102 { id: "u1", text: "live without server stamp yet", turn: 1 },
103 { id: "u2", text: "after hidden synthetic", turn: 2, checkpointTurn: 3 },
104 ]);
105 eq(backendTurns.get("u0"), 0, "uses backend checkpoint turn zero when present");
106 eq(backendTurns.get("u2"), 3, "uses non-contiguous backend checkpoint turn");
107 ok(!backendTurns.has("u1"), "does not mix visible ordinal fallback into authoritative checkpoint sessions");
108 eq(lastQuestionTurn([
109 { id: "u0", text: "first", turn: 0 },
110 { id: "u1", text: "second", turn: 1 },
111 ], visibleTurns), 1, "last question turn follows visible ordinal fallback");
112 eq(lastQuestionTurn([
113 { id: "u0", text: "first", turn: 0, checkpointTurn: 0 },
114 { id: "u1", text: "live without server stamp yet", turn: 1 },
115 { id: "u2", text: "after hidden synthetic", turn: 2, checkpointTurn: 3 },
116 ], backendTurns), 3, "last question turn follows non-contiguous backend turn");
117
118 const pagedTurns = questionTurnsById([
119 { id: "u-recent", text: "recent prompt", turn: 0, checkpointTurn: 1060 },
120 ]);
121 eq(lastQuestionTurn([
122 { id: "u-recent", text: "recent prompt", turn: 0, checkpointTurn: 1060 },
123 ], pagedTurns), 1060, "last question turn supports paged history windows");
124 }
125
126 {
127 const items = syntheticTranscriptItems(10_000, 1);
128 const start = performance.now();
129 const groups = buildTurnGroups(items);
130 const elapsed = performance.now() - start;
131 eq(groups.length, 10_000, "large transcript grouping keeps every turn");
132 ok(elapsed < 50, `groups 10k turns in ${elapsed.toFixed(2)}ms`);
133 }
134
135 {
136 const firstPage = warmPagination({ turnCount: 100, hotTurns: 30, pageSize: 20, coldPage: 0 });
137 eq(firstPage.warmStartTurn, 50, "long transcripts initially render only the latest warm page");
138 eq(firstPage.warmEndTurn, 70, "warm page stops before the hot zone");
139 eq(firstPage.coldTurnCount, 50, "older cold turns stay hidden behind the load-more button");
140
141 const secondPage = warmPagination({ turnCount: 100, hotTurns: 30, pageSize: 20, coldPage: 1 });
142 eq(secondPage.warmStartTurn, 30, "loading earlier history adds one more warm page");
143 eq(secondPage.warmEndTurn, 70, "loading earlier history keeps the hot-zone boundary stable");
144 eq(secondPage.coldTurnCount, 30, "loading earlier history reduces the hidden cold count");
145
146 const shortTranscript = warmPagination({ turnCount: 25, hotTurns: 30, pageSize: 20, coldPage: 0 });
147 eq(shortTranscript.warmStartTurn, 0, "short transcripts have no warm zone");
148 eq(shortTranscript.warmEndTurn, 0, "short transcripts have no warm boundary");
149 eq(shortTranscript.coldTurnCount, 0, "short transcripts have no cold turns");
150 }
151
152 {
153 eq(warmColdPageForTurn({ turn: 10, turnCount: 100, hotTurns: 30, pageSize: 20 }), 2, "jumping to cold-zone turn 10 loads enough warm pages");
154 eq(warmColdPageForTurn({ turn: 0, turnCount: 100, hotTurns: 30, pageSize: 20 }), 3, "jumping to the first warm turn loads all warm pages");
155 eq(warmColdPageForTurn({ turn: 65, turnCount: 100, hotTurns: 30, pageSize: 20 }), 0, "jumping inside the initial warm page needs no extra cold page");
156 eq(warmColdPageForTurn({ turn: 80, turnCount: 100, hotTurns: 30, pageSize: 20 }), 0, "jumping inside the hot zone needs no warm pagination");
157 }
158
159 {
160 let state = createWarmLayerState("tab-a|0|a-u0");
161 state = warmLayerWithNextColdPage(state, "tab-a|0|a-u0");
162 state = warmLayerWithNextColdPage(state, "tab-a|0|a-u0");
163 state = warmLayerWithNextColdPage(state, "tab-a|0|a-u0");
164 state = warmLayerWithExpandedTurn(state, "tab-a|0|a-u0", 10, true);
165 eq(state.coldPage, 3, "loading earlier history advances the current session page");
166 ok(state.expandedWarmTurns.has(10), "expanded warm turns stay scoped to the current session");
167
168 const switched = warmLayerForSession(state, "tab-b|1|b-u0");
169 eq(switched.coldPage, 0, "switching sessions resets warm pagination before rendering");
170 eq(switched.expandedWarmTurns.size, 0, "switching sessions clears expanded warm turns");
171
172 const switchedPage = warmPagination({ turnCount: 100, hotTurns: 30, pageSize: 20, coldPage: switched.coldPage });
173 eq(switchedPage.warmStartTurn, 50, "switched long transcripts render only the latest warm page first");
174
175 const paged = warmLayerWithColdPageAtLeast(switched, "tab-b|1|b-u0", 2);
176 eq(paged.coldPage, 2, "jumping to a cold-zone question raises the session warm page");
177 eq(warmLayerWithColdPageAtLeast(paged, "tab-b|1|b-u0", 1).coldPage, 2, "jumping never lowers the loaded warm page");
178 }
179
180 console.log(`\n${passed} passed, ${failed} failed`);
181 if (failed > 0) process.exit(1);
182
182 lines TYPESCRIPT