|
| 1 | +/** |
| 2 | + * Regression tests for compactToolInput() — rule-based tool input compaction |
| 3 | + * that reduces Write/Edit/Bash/TaskCreate/TaskUpdate inputs to structural |
| 4 | + * summaries without LLM involvement. |
| 5 | + * |
| 6 | + * Run: node __tests__/auto-capture-compaction.test.mjs |
| 7 | + */ |
| 8 | + |
| 9 | +import assert from "node:assert/strict"; |
| 10 | + |
| 11 | +// ─── Inline the functions under test ──────────────────────────────────────── |
| 12 | +// We copy the logic rather than importing to avoid coupling to the full |
| 13 | +// auto-capture.mjs module (which has side effects and I/O). |
| 14 | + |
| 15 | +function formatToolInput(value) { |
| 16 | + if (typeof value === "string") return value; |
| 17 | + try { |
| 18 | + return JSON.stringify(value, null, 2); |
| 19 | + } catch { |
| 20 | + return String(value); |
| 21 | + } |
| 22 | +} |
| 23 | + |
| 24 | +const TOOL_INPUT_POLICIES = { |
| 25 | + full: new Set([ |
| 26 | + "Read", "Glob", "Grep", "LSP", "WebFetch", "WebSearch", "Skill", |
| 27 | + ]), |
| 28 | + summary: new Set([ |
| 29 | + "Write", "Edit", "Bash", "TaskCreate", "TaskUpdate", |
| 30 | + ]), |
| 31 | +}; |
| 32 | + |
| 33 | +const PREVIEW_CHARS = 200; |
| 34 | +const DIFF_PREVIEW_CHARS = 150; |
| 35 | + |
| 36 | +function compactToolInput(toolName, value, maxChars = 0) { |
| 37 | + if (typeof value === "string") return value; |
| 38 | + |
| 39 | + if (!TOOL_INPUT_POLICIES.summary.has(toolName)) { |
| 40 | + const raw = formatToolInput(value); |
| 41 | + return maxChars > 0 && raw.length > maxChars |
| 42 | + ? raw.slice(0, maxChars) + `\n... [truncated, ${raw.length - maxChars} more chars]` |
| 43 | + : raw; |
| 44 | + } |
| 45 | + |
| 46 | + try { |
| 47 | + const obj = typeof value === "object" ? value : JSON.parse(value); |
| 48 | + let result; |
| 49 | + |
| 50 | + if (toolName === "Write") { |
| 51 | + const content = obj.content || ""; |
| 52 | + const lines = content.split("\n").length; |
| 53 | + result = JSON.stringify({ |
| 54 | + file_path: obj.file_path, |
| 55 | + content_summary: `${lines} lines, ${content.length} chars`, |
| 56 | + content_preview: content.slice(0, PREVIEW_CHARS), |
| 57 | + }); |
| 58 | + } else if (toolName === "Edit") { |
| 59 | + const oldStr = obj.old_string || ""; |
| 60 | + const newStr = obj.new_string || ""; |
| 61 | + result = JSON.stringify({ |
| 62 | + file_path: obj.file_path, |
| 63 | + replace_all: obj.replace_all || false, |
| 64 | + old_summary: `${oldStr.length} chars`, |
| 65 | + old_preview: oldStr.slice(0, DIFF_PREVIEW_CHARS), |
| 66 | + new_summary: `${newStr.length} chars`, |
| 67 | + new_preview: newStr.slice(0, DIFF_PREVIEW_CHARS), |
| 68 | + }); |
| 69 | + } else if (toolName === "Bash") { |
| 70 | + result = JSON.stringify({ command: obj.command }); |
| 71 | + } else if (toolName === "TaskCreate" || toolName === "TaskUpdate") { |
| 72 | + const summary = {}; |
| 73 | + if (obj.subject) summary.subject = obj.subject; |
| 74 | + if (obj.status) summary.status = obj.status; |
| 75 | + if (obj.taskId) summary.taskId = obj.taskId; |
| 76 | + result = JSON.stringify(summary); |
| 77 | + } else { |
| 78 | + result = formatToolInput(value); |
| 79 | + } |
| 80 | + |
| 81 | + if (maxChars > 0 && result.length > maxChars) { |
| 82 | + result = result.slice(0, maxChars) + `\n... [truncated]`; |
| 83 | + } |
| 84 | + return result; |
| 85 | + } catch { |
| 86 | + return formatToolInput(value); |
| 87 | + } |
| 88 | +} |
| 89 | + |
| 90 | +// ─── Helpers ──────────────────────────────────────────────────────────────── |
| 91 | + |
| 92 | +let pass = 0; |
| 93 | +let fail = 0; |
| 94 | + |
| 95 | +function test(name, fn) { |
| 96 | + try { |
| 97 | + fn(); |
| 98 | + pass++; |
| 99 | + console.log(` ✓ ${name}`); |
| 100 | + } catch (e) { |
| 101 | + fail++; |
| 102 | + console.log(` ✗ ${name}`); |
| 103 | + console.log(` ${e.message}`); |
| 104 | + } |
| 105 | +} |
| 106 | + |
| 107 | +// Generate a long string of repeated lines |
| 108 | +function longContent(lines, charsPerLine = 80) { |
| 109 | + const line = "x".repeat(charsPerLine); |
| 110 | + return Array.from({ length: lines }, () => line).join("\n"); |
| 111 | +} |
| 112 | + |
| 113 | +// ─── Tests ────────────────────────────────────────────────────────────────── |
| 114 | + |
| 115 | +console.log("\ncompactToolInput regression tests\n"); |
| 116 | + |
| 117 | +// 1. Write compaction — 7KB content → summary with file_path + line count + 200-char preview |
| 118 | +test("Write: 7KB content compacted to structural summary", () => { |
| 119 | + const content = longContent(100, 72); // ~7200 chars |
| 120 | + const input = { file_path: "/src/index.ts", content }; |
| 121 | + const result = compactToolInput("Write", input); |
| 122 | + const parsed = JSON.parse(result); |
| 123 | + |
| 124 | + assert.equal(parsed.file_path, "/src/index.ts"); |
| 125 | + assert.ok(parsed.content_summary.includes("100 lines"), `expected line count in summary, got: ${parsed.content_summary}`); |
| 126 | + assert.ok(parsed.content_preview.length <= 200, `preview should be ≤200 chars, got ${parsed.content_preview.length}`); |
| 127 | + // Compression ratio: original ~7200 chars vs compacted ~350 chars |
| 128 | + assert.ok(result.length < 500, `compacted should be <500 chars, got ${result.length}`); |
| 129 | + assert.ok(content.length / result.length > 10, `compression ratio should be >10x, got ${(content.length / result.length).toFixed(1)}x`); |
| 130 | +}); |
| 131 | + |
| 132 | +// 2. Edit compaction — old_string/new_string → file_path + length summary + 150-char previews |
| 133 | +test("Edit: old_string/new_string compacted to diff summary", () => { |
| 134 | + const oldStr = longContent(50, 60); // ~3000 chars |
| 135 | + const newStr = longContent(30, 60); // ~1800 chars |
| 136 | + const input = { |
| 137 | + file_path: "/src/utils.mjs", |
| 138 | + old_string: oldStr, |
| 139 | + new_string: newStr, |
| 140 | + replace_all: false, |
| 141 | + }; |
| 142 | + const result = compactToolInput("Edit", input); |
| 143 | + const parsed = JSON.parse(result); |
| 144 | + |
| 145 | + assert.equal(parsed.file_path, "/src/utils.mjs"); |
| 146 | + assert.equal(parsed.replace_all, false); |
| 147 | + assert.ok(parsed.old_summary.includes("chars"), `old_summary should mention chars, got: ${parsed.old_summary}`); |
| 148 | + assert.ok(parsed.new_summary.includes("chars"), `new_summary should mention chars, got: ${parsed.new_summary}`); |
| 149 | + assert.ok(parsed.old_preview.length <= 150, `old_preview should be ≤150 chars, got ${parsed.old_preview.length}`); |
| 150 | + assert.ok(parsed.new_preview.length <= 150, `new_preview should be ≤150 chars, got ${parsed.new_preview.length}`); |
| 151 | + // Compression: ~4800 chars → ~400 chars |
| 152 | + assert.ok(result.length < 600, `compacted should be <600 chars, got ${result.length}`); |
| 153 | +}); |
| 154 | + |
| 155 | +// 3. Bash compaction — keep command, drop description |
| 156 | +test("Bash: keep command, drop description", () => { |
| 157 | + const input = { |
| 158 | + command: "git rebase -i HEAD~5", |
| 159 | + description: "Interactively rebase the last 5 commits to squash and reorder", |
| 160 | + }; |
| 161 | + const result = compactToolInput("Bash", input); |
| 162 | + const parsed = JSON.parse(result); |
| 163 | + |
| 164 | + assert.equal(parsed.command, "git rebase -i HEAD~5"); |
| 165 | + assert.equal(parsed.description, undefined, "description should be dropped"); |
| 166 | +}); |
| 167 | + |
| 168 | +// 4. TaskCreate/TaskUpdate compaction — subject + status only |
| 169 | +test("TaskCreate: only subject + status preserved", () => { |
| 170 | + const input = { |
| 171 | + subject: "Fix auth middleware", |
| 172 | + description: "The auth middleware is failing because...", |
| 173 | + activeForm: "Fixing auth middleware", |
| 174 | + status: "in_progress", |
| 175 | + }; |
| 176 | + const result = compactToolInput("TaskCreate", input); |
| 177 | + const parsed = JSON.parse(result); |
| 178 | + |
| 179 | + assert.equal(parsed.subject, "Fix auth middleware"); |
| 180 | + assert.equal(parsed.status, "in_progress"); |
| 181 | + assert.equal(parsed.description, undefined, "description should be dropped"); |
| 182 | + assert.equal(parsed.activeForm, undefined, "activeForm should be dropped"); |
| 183 | +}); |
| 184 | + |
| 185 | +test("TaskUpdate: subject + status + taskId preserved", () => { |
| 186 | + const input = { |
| 187 | + taskId: "42", |
| 188 | + subject: "Write tests", |
| 189 | + status: "completed", |
| 190 | + description: "Detailed description here", |
| 191 | + owner: "agent-1", |
| 192 | + }; |
| 193 | + const result = compactToolInput("TaskUpdate", input); |
| 194 | + const parsed = JSON.parse(result); |
| 195 | + |
| 196 | + assert.equal(parsed.subject, "Write tests"); |
| 197 | + assert.equal(parsed.status, "completed"); |
| 198 | + assert.equal(parsed.taskId, "42"); |
| 199 | + assert.equal(parsed.description, undefined); |
| 200 | + assert.equal(parsed.owner, undefined); |
| 201 | +}); |
| 202 | + |
| 203 | +// 5. Read/Glob/Grep — full preservation (not in summary set) |
| 204 | +test("Read: full preservation (not compacted)", () => { |
| 205 | + const input = { file_path: "/src/index.ts" }; |
| 206 | + const result = compactToolInput("Read", input); |
| 207 | + const parsed = JSON.parse(result); |
| 208 | + |
| 209 | + assert.equal(parsed.file_path, "/src/index.ts"); |
| 210 | + // Should be identical to formatToolInput output |
| 211 | + assert.equal(result, formatToolInput(input)); |
| 212 | +}); |
| 213 | + |
| 214 | +test("Glob: full preservation", () => { |
| 215 | + const input = { pattern: "**/*.mjs", path: "/src" }; |
| 216 | + const result = compactToolInput("Glob", input); |
| 217 | + assert.equal(result, formatToolInput(input)); |
| 218 | +}); |
| 219 | + |
| 220 | +test("Grep: full preservation", () => { |
| 221 | + const input = { pattern: "compactToolInput", path_filter: "^src/" }; |
| 222 | + const result = compactToolInput("Grep", input); |
| 223 | + assert.equal(result, formatToolInput(input)); |
| 224 | +}); |
| 225 | + |
| 226 | +test("WebSearch: full preservation", () => { |
| 227 | + const input = { query: "bge-large-zh embedding model" }; |
| 228 | + const result = compactToolInput("WebSearch", input); |
| 229 | + assert.equal(result, formatToolInput(input)); |
| 230 | +}); |
| 231 | + |
| 232 | +// 6. TOOL_INPUT_MAX_CHARS truncation — cap applied to full-preservation tools |
| 233 | +test("maxChars truncation on full-preservation tool", () => { |
| 234 | + const input = { file_path: "/src/index.ts", query: "x".repeat(5000) }; |
| 235 | + const maxChars = 200; |
| 236 | + const result = compactToolInput("Grep", input, maxChars); |
| 237 | + |
| 238 | + assert.ok(result.length > maxChars, "truncation marker adds length beyond cap"); |
| 239 | + assert.ok(result.includes("[truncated"), "should include truncation marker"); |
| 240 | + assert.ok(result.startsWith(formatToolInput(input).slice(0, maxChars)), |
| 241 | + "should start with the first maxChars of the full output"); |
| 242 | +}); |
| 243 | + |
| 244 | +test("maxChars=0 disables truncation", () => { |
| 245 | + const input = { file_path: "/src/index.ts", query: "x".repeat(5000) }; |
| 246 | + const result = compactToolInput("Grep", input, 0); |
| 247 | + assert.equal(result, formatToolInput(input), "no truncation when maxChars=0"); |
| 248 | +}); |
| 249 | + |
| 250 | +// 7. compaction=off fallback — when useCompaction=false, caller uses formatToolInput directly |
| 251 | +// (this tests that the caller path works; compactToolInput itself always compacts |
| 252 | +// summary-set tools, but the harvestContent branch skips it when cfg.toolInputCompaction===false) |
| 253 | +test("compaction=off: formatToolInput used directly (caller responsibility)", () => { |
| 254 | + const input = { |
| 255 | + file_path: "/src/index.ts", |
| 256 | + content: longContent(100, 72), |
| 257 | + }; |
| 258 | + // When compaction is off, the caller uses formatToolInput, not compactToolInput |
| 259 | + const fullResult = formatToolInput(input); |
| 260 | + const compactResult = compactToolInput("Write", input); |
| 261 | + |
| 262 | + // Full result should be much larger |
| 263 | + assert.ok(fullResult.length > compactResult.length * 5, |
| 264 | + `full should be >5x compacted: full=${fullResult.length}, compact=${compactResult.length}`); |
| 265 | + // Compact result should NOT contain the full content |
| 266 | + assert.ok(!compactResult.includes(input.content), |
| 267 | + "compacted should not contain full content"); |
| 268 | +}); |
| 269 | + |
| 270 | +// 8. JSON round-trip — compactToolInput output is valid JSON for summary tools |
| 271 | +test("Write: output is valid JSON", () => { |
| 272 | + const input = { file_path: "/src/a.ts", content: "export const x = 1;\n" }; |
| 273 | + const result = compactToolInput("Write", input); |
| 274 | + const parsed = JSON.parse(result); |
| 275 | + assert.equal(parsed.file_path, "/src/a.ts"); |
| 276 | +}); |
| 277 | + |
| 278 | +test("Edit: output is valid JSON", () => { |
| 279 | + const input = { file_path: "/src/a.ts", old_string: "foo", new_string: "bar" }; |
| 280 | + const result = compactToolInput("Edit", input); |
| 281 | + const parsed = JSON.parse(result); |
| 282 | + assert.equal(parsed.file_path, "/src/a.ts"); |
| 283 | +}); |
| 284 | + |
| 285 | +test("Bash: output is valid JSON", () => { |
| 286 | + const input = { command: "ls -la" }; |
| 287 | + const result = compactToolInput("Bash", input); |
| 288 | + const parsed = JSON.parse(result); |
| 289 | + assert.equal(parsed.command, "ls -la"); |
| 290 | +}); |
| 291 | + |
| 292 | +test("TaskCreate: output is valid JSON", () => { |
| 293 | + const input = { subject: "Do thing", status: "pending" }; |
| 294 | + const result = compactToolInput("TaskCreate", input); |
| 295 | + const parsed = JSON.parse(result); |
| 296 | + assert.equal(parsed.subject, "Do thing"); |
| 297 | +}); |
| 298 | + |
| 299 | +// ─── Edge cases ───────────────────────────────────────────────────────────── |
| 300 | + |
| 301 | +test("string input passes through unchanged", () => { |
| 302 | + assert.equal(compactToolInput("Write", "just a string"), "just a string"); |
| 303 | +}); |
| 304 | + |
| 305 | +test("unknown tool in summary set falls back to formatToolInput", () => { |
| 306 | + // If a tool name is in the summary set but has no specific handler, |
| 307 | + // it falls through to the else branch → formatToolInput |
| 308 | + const input = { some: "data" }; |
| 309 | + const result = compactToolInput("TaskUpdate", { subject: "x" }); // has handler |
| 310 | + assert.ok(JSON.parse(result).subject === "x"); |
| 311 | +}); |
| 312 | + |
| 313 | +test("Edit with empty old_string/new_string", () => { |
| 314 | + const input = { file_path: "/src/a.ts", old_string: "", new_string: "new code" }; |
| 315 | + const result = compactToolInput("Edit", input); |
| 316 | + const parsed = JSON.parse(result); |
| 317 | + assert.equal(parsed.old_summary, "0 chars"); |
| 318 | + assert.ok(parsed.new_preview.includes("new code")); |
| 319 | +}); |
| 320 | + |
| 321 | +test("Write with empty content", () => { |
| 322 | + const input = { file_path: "/src/a.ts", content: "" }; |
| 323 | + const result = compactToolInput("Write", input); |
| 324 | + const parsed = JSON.parse(result); |
| 325 | + assert.equal(parsed.content_summary, "1 lines, 0 chars"); // empty string has 1 line |
| 326 | +}); |
| 327 | + |
| 328 | +// ─── Compression ratio on realistic data ───────────────────────────────────── |
| 329 | + |
| 330 | +test("Compression ratio: realistic mixed session data", () => { |
| 331 | + // Simulate a realistic mix of tool calls from a CC session |
| 332 | + const toolCalls = [ |
| 333 | + { name: "Write", input: { file_path: "/src/index.ts", content: longContent(120, 65) } }, |
| 334 | + { name: "Edit", input: { file_path: "/src/utils.mjs", old_string: longContent(30, 60), new_string: longContent(25, 60) } }, |
| 335 | + { name: "Edit", input: { file_path: "/src/config.mjs", old_string: "const x = 1;", new_string: "const x = 2;" } }, |
| 336 | + { name: "Bash", input: { command: "npm test", description: "Run the test suite" } }, |
| 337 | + { name: "Bash", input: { command: "git status", description: "Show working tree status" } }, |
| 338 | + { name: "Read", input: { file_path: "/src/index.ts" } }, |
| 339 | + { name: "Grep", input: { pattern: "compactToolInput", path_filter: "^src/" } }, |
| 340 | + { name: "TaskCreate", input: { subject: "Add tests", description: "Write regression tests", status: "pending" } }, |
| 341 | + { name: "TaskUpdate", input: { taskId: "1", subject: "Add tests", status: "completed", description: "Done" } }, |
| 342 | + ]; |
| 343 | + |
| 344 | + let fullSize = 0; |
| 345 | + let compactSize = 0; |
| 346 | + |
| 347 | + for (const tc of toolCalls) { |
| 348 | + fullSize += formatToolInput(tc.input).length; |
| 349 | + compactSize += compactToolInput(tc.name, tc.input).length; |
| 350 | + } |
| 351 | + |
| 352 | + const ratio = fullSize / compactSize; |
| 353 | + console.log(` full: ${fullSize} chars, compact: ${compactSize} chars, ratio: ${ratio.toFixed(1)}x`); |
| 354 | + |
| 355 | + // With realistic data, expect at least 3x compression |
| 356 | + assert.ok(ratio >= 3, `expected ≥3x compression, got ${ratio.toFixed(1)}x`); |
| 357 | +}); |
| 358 | + |
| 359 | +// ─── Summary ──────────────────────────────────────────────────────────────── |
| 360 | + |
| 361 | +console.log(`\n${pass + fail} tests: ${pass} passed, ${fail} failed\n`); |
| 362 | +process.exit(fail > 0 ? 1 : 0); |
0 commit comments