返回 DeepSeek-Reasonix
tool-data-archive.test.ts
根目录 / desktop / frontend / src / __tests__ / tool-data-archive.test.ts
1 // Run: tsx src/__tests__/tool-data-archive.test.ts
2 //
3 // Verifies that the tool_result reducer archives completed tools immediately:
4 // output is dropped, dataArchived is set, and most args are trimmed to 200
5 // chars. For todo_write, only the latest successful top-level snapshot keeps
6 // full JSON because the bottom task panel parses that canonical entry directly.
7
8 import { initialState, reducer } from "../lib/useController";
9 import type { Item } from "../lib/useController";
10
11 type TestState = typeof initialState;
12 type ToolItem = Extract<Item, { kind: "tool" }>;
13
14 let passed = 0;
15 let failed = 0;
16
17 function eq<T>(a: T, b: T, label: string) {
18 if (a === b) {
19 process.stdout.write(` PASS ${label}\n`);
20 passed += 1;
21 } else {
22 const expected = JSON.stringify(b) ?? String(b);
23 const actual = JSON.stringify(a) ?? String(a);
24 process.stdout.write(` FAIL ${label}: expected ${expected.slice(0, 120)}, got ${actual.slice(0, 120)}\n`);
25 failed += 1;
26 }
27 }
28
29 function ok(cond: boolean, label: string) {
30 if (cond) {
31 process.stdout.write(` PASS ${label}\n`);
32 passed += 1;
33 } else {
34 process.stdout.write(` FAIL ${label}\n`);
35 failed += 1;
36 }
37 }
38
39 /** Run tool_dispatch + tool_result for each item and return final state. */
40 function addTools(state: TestState, count: number, argsLen = 5000, outputLen = 10000): TestState {
41 let s = state;
42 for (let i = 0; i < count; i++) {
43 const id = `t${i}`;
44 s = reducer(s, { type: "event", e: { kind: "turn_started" } });
45 s = reducer(s, { type: "event", e: { kind: "tool_dispatch", tool: { id, name: "bash", args: "x".repeat(argsLen), readOnly: false } } });
46 s = reducer(s, { type: "event", e: { kind: "tool_result", tool: { id, name: "bash", readOnly: false, output: "y".repeat(outputLen), durationMs: 100 } } });
47 }
48 return s;
49 }
50
51 function toolItems(s: TestState): ToolItem[] {
52 return s.items.filter((it): it is ToolItem => it.kind === "tool");
53 }
54
55 function todoArgs(label: string, active = 0): string {
56 return JSON.stringify({
57 todos: Array.from({ length: 8 }, (_, i) => ({
58 content: `${label} task ${i} ${"x".repeat(30)}`,
59 status: i === active ? "in_progress" : "pending",
60 })),
61 });
62 }
63
64 console.log("\ntool data archiving on tool_result");
65
66 // ── Test 1: Every completed tool is archived immediately ──
67 {
68 let s = addTools(initialState, 1, 5000, 10000);
69 const tools = toolItems(s);
70 ok(tools.length >= 1, "tool item exists after tool_result");
71 ok(tools[0].dataArchived === true, "single tool is archived immediately");
72 eq(tools[0].output, undefined, "output is dropped");
73 ok((tools[0].args?.length ?? 0) <= 205, `args truncated to ≤200 chars (got ${tools[0].args?.length})`);
74 }
75
76 // ── Test 2: Multiple tools all archived (no threshold) ──
77 {
78 let s = addTools(initialState, 50, 5000, 10000);
79 const tools = toolItems(s);
80 ok(tools.length >= 50, `${tools.length} tools present`);
81 const allArchived = tools.every((t) => t.dataArchived === true);
82 ok(allArchived, "all 50 tools archived immediately");
83 const allNoOutput = tools.every((t) => t.output === undefined);
84 ok(allNoOutput, "all tools have output dropped");
85 const maxArgs = Math.max(...tools.map((t) => t.args?.length ?? 0));
86 ok(maxArgs <= 205, `all args ≤200 chars (max ${maxArgs})`);
87 }
88
89 // ── Test 3: Undefined output doesn't crash ──
90 {
91 let s = initialState;
92 s = reducer(s, { type: "event", e: { kind: "turn_started" } });
93 s = reducer(s, { type: "event", e: { kind: "tool_dispatch", tool: { id: "noop", name: "glob", args: JSON.stringify({ pattern: "**/*" }), readOnly: true } } });
94 s = reducer(s, { type: "event", e: { kind: "tool_result", tool: { id: "noop", name: "glob", readOnly: true, output: undefined, durationMs: 5 } } });
95 const tools = toolItems(s);
96 ok(tools.length >= 1, "no crash when tool output is undefined");
97 }
98
99 // ── Test 4: Running (in-flight) tools keep full args for subject/UI ──
100 {
101 let s = initialState;
102 s = reducer(s, { type: "event", e: { kind: "turn_started" } });
103 s = reducer(s, { type: "event", e: { kind: "tool_dispatch", tool: { id: "run1", name: "bash", args: '{"command":"echo hello"}', readOnly: false } } });
104 // Before tool_result: tool is running, args should still be full
105 const before = toolItems(s);
106 ok(before.length >= 1, "tool exists while running");
107 eq(before[0].status, "running", "tool is running");
108 eq(before[0].dataArchived, undefined, "running tool not archived yet");
109 eq(before[0].args, '{"command":"echo hello"}', "running tool keeps full args");
110
111 // After tool_result: archived
112 s = reducer(s, { type: "event", e: { kind: "tool_result", tool: { id: "run1", name: "bash", readOnly: false, output: "hello world", durationMs: 50 } } });
113 const after = toolItems(s);
114 ok(after[0].dataArchived === true, "tool archived after result");
115 eq(after[0].output, undefined, "output dropped after result");
116 }
117
118 // ── Test 5: Total string size reduction in a long session ──
119 {
120 const TOOL_COUNT = 500;
121 const ARGS_SIZE = 5000;
122 const OUTPUT_SIZE = 10000;
123 let s = addTools(initialState, TOOL_COUNT, ARGS_SIZE, OUTPUT_SIZE);
124 const tools = toolItems(s);
125 ok(tools.length >= TOOL_COUNT, `${tools.length} tools present`);
126
127 // All tools should be archived: args ≤200, no output
128 const totalStringBytes = tools.reduce((sum, t) => sum + (t.args?.length ?? 0) + (t.output?.length ?? 0), 0);
129 // Expected: each tool has ~200 chars args + 0 output = ~200 per tool
130 const expectedMax = TOOL_COUNT * 205;
131 ok(totalStringBytes <= expectedMax, `total string size ${totalStringBytes.toLocaleString()} ≤ ${expectedMax.toLocaleString()} (${(100 * totalStringBytes / expectedMax).toFixed(0)}% of max)`);
132
133 const withoutArchive = TOOL_COUNT * (ARGS_SIZE + OUTPUT_SIZE);
134 const reduction = (withoutArchive - totalStringBytes) / withoutArchive;
135 ok(reduction > 0.95, `archive removed ${(reduction * 100).toFixed(0)}% of tool string data`);
136 }
137
138 // ── Test 6: Restored history starts light, without a full-output transient ──
139 {
140 const output = "z".repeat(100_000);
141 const args = JSON.stringify({ command: "printf z" });
142 const s = reducer(initialState, {
143 type: "history",
144 messages: [
145 {
146 role: "assistant",
147 content: "",
148 toolCalls: [{
149 id: "hist-bash",
150 name: "bash",
151 arguments: "",
152 argumentsArchived: true,
153 subject: "printf z",
154 summary: "1 line",
155 }],
156 },
157 {
158 role: "tool",
159 content: "",
160 toolCallId: "hist-bash",
161 toolName: "bash",
162 toolResultArchived: true,
163 },
164 ] as any,
165 });
166 const tools = toolItems(s);
167 ok(tools.length === 1, "history restored one archived tool");
168 eq(tools[0].dataArchived, true, "history archived tool is marked archived");
169 eq(tools[0].output, undefined, "history archived tool has no output");
170 eq(tools[0].args, "", "history archived tool has no args");
171 eq(tools[0].subject, "printf z", "history archived tool keeps subject");
172 eq(tools[0].summary, "1 line", "history archived tool keeps summary");
173 const totalStringBytes = tools.reduce((sum, t) => sum + (t.args?.length ?? 0) + (t.output?.length ?? 0), 0);
174 ok(totalStringBytes < args.length + output.length, "history restore avoids large args/output strings");
175 }
176
177 // ── Test 7: History keeps only the latest successful top-level todo_write full ──
178 {
179 const oldArgs = todoArgs("old");
180 const latestArgs = todoArgs("latest", 2);
181 const s = reducer(initialState, {
182 type: "history",
183 messages: [
184 {
185 role: "assistant",
186 content: "",
187 toolCalls: [{
188 id: "todo-old",
189 name: "todo_write",
190 arguments: oldArgs,
191 }],
192 },
193 {
194 role: "tool",
195 content: "",
196 toolCallId: "todo-old",
197 toolName: "todo_write",
198 toolResultArchived: true,
199 },
200 {
201 role: "assistant",
202 content: "",
203 toolCalls: [{
204 id: "todo-latest",
205 name: "todo_write",
206 arguments: latestArgs,
207 }],
208 },
209 {
210 role: "tool",
211 content: "",
212 toolCallId: "todo-latest",
213 toolName: "todo_write",
214 toolResultArchived: true,
215 },
216 ] as any,
217 });
218 const tools = toolItems(s);
219 const oldTodo = tools.find((tool) => tool.id === "todo-old");
220 const latestTodo = tools.find((tool) => tool.id === "todo-latest");
221 ok(Boolean(oldTodo), "history restored older todo_write");
222 ok(Boolean(latestTodo), "history restored latest todo_write");
223 ok((oldTodo?.args.length ?? 0) <= 205, "older todo_write args are truncated during history restore");
224 ok(oldTodo?.args !== oldArgs, "older todo_write no longer keeps full JSON");
225 eq(latestTodo?.args, latestArgs, "latest todo_write keeps full args during history restore");
226 eq(JSON.parse(latestTodo?.args ?? "{}").todos.length, 8, "latest todo_write args remain parseable JSON");
227 }
228
229 // ── Test 8: Live updates keep only the latest successful top-level todo_write full ──
230 {
231 const firstArgs = todoArgs("first");
232 const latestArgs = todoArgs("latest", 3);
233 let s = initialState;
234 s = reducer(s, { type: "event", e: { kind: "turn_started" } });
235 s = reducer(s, {
236 type: "event",
237 e: {
238 kind: "tool_dispatch",
239 tool: { id: "todo-first", name: "todo_write", args: firstArgs, readOnly: true },
240 },
241 });
242 s = reducer(s, {
243 type: "event",
244 e: {
245 kind: "tool_result",
246 tool: { id: "todo-first", name: "todo_write", readOnly: true, output: "Todos updated", durationMs: 15 },
247 },
248 });
249 s = reducer(s, {
250 type: "event",
251 e: {
252 kind: "tool_dispatch",
253 tool: { id: "todo-latest", name: "todo_write", args: latestArgs, readOnly: true },
254 },
255 });
256 s = reducer(s, {
257 type: "event",
258 e: {
259 kind: "tool_result",
260 tool: { id: "todo-latest", name: "todo_write", readOnly: true, output: "Todos updated", durationMs: 20 },
261 },
262 });
263
264 const tools = toolItems(s);
265 const firstTodo = tools.find((tool) => tool.id === "todo-first");
266 const latestTodo = tools.find((tool) => tool.id === "todo-latest");
267 ok(Boolean(firstTodo), "first live todo_write result is recorded");
268 ok(Boolean(latestTodo), "latest live todo_write result is recorded");
269 eq(firstTodo?.dataArchived, true, "older live todo_write stays archived");
270 ok((firstTodo?.args.length ?? 0) <= 205, "older live todo_write args are truncated");
271 eq(latestTodo?.dataArchived, true, "latest live todo_write still marks output as archived");
272 eq(latestTodo?.args, latestArgs, "latest live todo_write keeps full args");
273 eq(JSON.parse(latestTodo?.args ?? "{}").todos.length, 8, "latest live todo_write args remain parseable JSON");
274 }
275
276 // ── Test 9: A later failed todo_write does not steal the canonical snapshot ──
277 {
278 const successArgs = todoArgs("success", 1);
279 const failedArgs = todoArgs("failed", 4);
280 let s = initialState;
281 s = reducer(s, { type: "event", e: { kind: "turn_started" } });
282 s = reducer(s, {
283 type: "event",
284 e: {
285 kind: "tool_dispatch",
286 tool: { id: "todo-success", name: "todo_write", args: successArgs, readOnly: true },
287 },
288 });
289 s = reducer(s, {
290 type: "event",
291 e: {
292 kind: "tool_result",
293 tool: { id: "todo-success", name: "todo_write", readOnly: true, output: "Todos updated", durationMs: 15 },
294 },
295 });
296 s = reducer(s, {
297 type: "event",
298 e: {
299 kind: "tool_dispatch",
300 tool: { id: "todo-failed", name: "todo_write", args: failedArgs, readOnly: true },
301 },
302 });
303 s = reducer(s, {
304 type: "event",
305 e: {
306 kind: "tool_result",
307 tool: { id: "todo-failed", name: "todo_write", readOnly: true, err: "write failed", durationMs: 15 },
308 },
309 });
310
311 const tools = toolItems(s);
312 const successTodo = tools.find((tool) => tool.id === "todo-success");
313 const failedTodo = tools.find((tool) => tool.id === "todo-failed");
314 eq(successTodo?.args, successArgs, "previous successful todo_write remains canonical after a later failure");
315 ok((failedTodo?.args.length ?? 0) <= 205, "failed todo_write args are truncated");
316 eq(failedTodo?.status, "error", "failed todo_write keeps error status");
317 }
318
319 // ── Test 10: A successful empty todo_write becomes canonical without unarchiving older args ──
320 {
321 const oldArgs = todoArgs("old");
322 const clearArgs = `{"todos":[]}`;
323 let s = initialState;
324 s = reducer(s, { type: "event", e: { kind: "turn_started" } });
325 s = reducer(s, { type: "event", e: { kind: "tool_dispatch", tool: { id: "todo-old", name: "todo_write", args: oldArgs, readOnly: true } } });
326 s = reducer(s, { type: "event", e: { kind: "tool_result", tool: { id: "todo-old", name: "todo_write", readOnly: true, output: "Todos updated" } } });
327 s = reducer(s, { type: "event", e: { kind: "tool_dispatch", tool: { id: "todo-clear", name: "todo_write", args: clearArgs, readOnly: true } } });
328 s = reducer(s, { type: "event", e: { kind: "tool_result", tool: { id: "todo-clear", name: "todo_write", readOnly: true, output: "Todos updated" } } });
329
330 const tools = toolItems(s);
331 const oldTodo = tools.find((tool) => tool.id === "todo-old");
332 const clearTodo = tools.find((tool) => tool.id === "todo-clear");
333 ok((oldTodo?.args.length ?? 0) <= 205, "older todo_write args stay archived when latest todo_write clears the list");
334 ok(oldTodo?.args !== oldArgs, "older todo_write does not keep full JSON after a clear");
335 eq(clearTodo?.args, clearArgs, "empty todo_write clear keeps parseable canonical args");
336 eq(JSON.parse(clearTodo?.args ?? "{}").todos.length, 0, "empty todo_write clear remains parseable as the latest canonical list");
337 }
338
339 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
340 if (failed > 0) process.exit(1);
341
341 lines TYPESCRIPT