| 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 |