import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { buildTruncatedContent, derefToolResultReReads, getToolResultFilePath, postTurnTruncateToolResults, REREAD_STUB, TARGET_CHARS, THRESHOLD_CHARS, TOOL_RESULT_DIR, TRUNCATION_EXEMPT_TOOLS, TRUNCATION_MARKER, } from "../context/post-turn-tool-result-truncation.js"; import type { ContentBlock, Message } from "../providers/types.js"; import { parseExternalContentEnvelope, wrapUntrustedContent, } from "../security/untrusted-content.js"; function makeToolResult( content: string, toolUseId = "tool_use_1", is_error = false, ): ContentBlock { return { type: "tool_result" as const, tool_use_id: toolUseId, content, ...(is_error ? { is_error: true } : {}), }; } function makeMessages(blocks: ContentBlock[]): Message[] { return [{ role: "user", content: blocks }]; } describe("postTurnTruncateToolResults", () => { let convDir: string; beforeEach(() => { convDir = mkdtempSync(join(tmpdir(), "tool-result-trunc-")); }); afterEach(() => { rmSync(convDir, { recursive: true, force: true }); }); test("result below threshold is returned unchanged, no file written", () => { const shortContent = "a".repeat(THRESHOLD_CHARS); const messages = makeMessages([makeToolResult(shortContent)]); const { messages: result, truncatedCount } = postTurnTruncateToolResults( messages, { conversationDir: convDir }, ); expect(truncatedCount).toBe(0); expect(result).toBe(messages); // same reference — no copy expect(existsSync(join(convDir, TOOL_RESULT_DIR))).toBe(false); }); test("result above threshold is truncated, file written with original content", () => { const longContent = "x".repeat(THRESHOLD_CHARS + 1); const toolUseId = "tool_use_abc"; const messages = makeMessages([makeToolResult(longContent, toolUseId)]); const { messages: result, truncatedCount } = postTurnTruncateToolResults( messages, { conversationDir: convDir }, ); expect(truncatedCount).toBe(1); const block = result[0].content[0] as { type: "tool_result"; content: string; }; expect(block.content).toContain(TRUNCATION_MARKER); expect(block.content.length).toBeLessThan(longContent.length); // Verify file on disk contains original content. const filePath = getToolResultFilePath(convDir, toolUseId); expect(existsSync(filePath)).toBe(true); expect(readFileSync(filePath, "utf-8")).toBe(longContent); }); test("error result above threshold is unchanged", () => { const longContent = "e".repeat(THRESHOLD_CHARS + 100); const messages = makeMessages([ makeToolResult(longContent, "tool_err", true), ]); const { messages: result, truncatedCount } = postTurnTruncateToolResults( messages, { conversationDir: convDir }, ); expect(truncatedCount).toBe(0); expect(result).toBe(messages); }); test("already-truncated result is unchanged (idempotency)", () => { // Simulate a result that was already truncated in a prior pass. const alreadyTruncated = "prefix..." + `\n\n...(500 tokens omitted ${TRUNCATION_MARKER} /some/path.txt)\n\n` + "...suffix".padEnd(THRESHOLD_CHARS + 1, "z"); const messages = makeMessages([ makeToolResult(alreadyTruncated, "tool_idempotent"), ]); const { messages: result, truncatedCount } = postTurnTruncateToolResults( messages, { conversationDir: convDir }, ); expect(truncatedCount).toBe(0); expect(result).toBe(messages); }); test("multiple results in one turn are each evaluated independently", () => { const short = "s".repeat(100); const long1 = "a".repeat(THRESHOLD_CHARS + 1); const long2 = "b".repeat(THRESHOLD_CHARS + 2); const messages = makeMessages([ makeToolResult(short, "tool_short"), makeToolResult(long1, "tool_long1"), makeToolResult(long2, "tool_long2"), ]); const { messages: result, truncatedCount } = postTurnTruncateToolResults( messages, { conversationDir: convDir }, ); expect(truncatedCount).toBe(2); // Short result unchanged. const b0 = result[0].content[0] as { type: "tool_result"; content: string }; expect(b0.content).toBe(short); // Both long results truncated. const b1 = result[0].content[1] as { type: "tool_result"; content: string }; const b2 = result[0].content[2] as { type: "tool_result"; content: string }; expect(b1.content).toContain(TRUNCATION_MARKER); expect(b2.content).toContain(TRUNCATION_MARKER); }); test("prefix/suffix split preserves first and last halves of TARGET_CHARS", () => { // Build content where each char is its position modulo 10 so we can verify slicing. const longContent = Array.from({ length: THRESHOLD_CHARS + 500 }, (_, i) => String(i % 10), ).join(""); const filePath = "/tmp/fake-path.txt"; const stub = buildTruncatedContent(longContent, filePath); const half = Math.floor(TARGET_CHARS / 2); const expectedPrefix = longContent.slice(0, half); const expectedSuffix = longContent.slice(-half); expect(stub.startsWith(expectedPrefix)).toBe(true); expect(stub.endsWith(expectedSuffix)).toBe(true); expect(stub).toContain(TRUNCATION_MARKER); expect(stub).toContain(filePath); // The marker must state how to page the content back in — it is the // model's only signal that the omitted middle is recoverable. expect(stub).toContain("use file_read to view"); }); test("skill_load result above threshold is NOT truncated (durable instructions exempt)", () => { // Regression for JARVIS-1000: a hosted assistant lost its app-builder skill // workflow when the large skill_load result was middle-truncated between the // turn that loaded the skill and the turn that used it, then fell back to a // local-dev (vite/localhost) build path. const toolUseId = "tool_skill_load"; const skillBody = "S".repeat(THRESHOLD_CHARS + 5_000); const messages: Message[] = [ { role: "assistant", content: [ { type: "tool_use" as const, id: toolUseId, name: "skill_load", input: { skill: "app-builder" }, }, ], }, { role: "user", content: [makeToolResult(skillBody, toolUseId)] }, ]; const { messages: result, truncatedCount } = postTurnTruncateToolResults( messages, { conversationDir: convDir }, ); expect(truncatedCount).toBe(0); expect(result).toBe(messages); // same reference — no copy expect(existsSync(join(convDir, TOOL_RESULT_DIR))).toBe(false); const block = result[1].content[0] as { type: "tool_result"; content: string; }; expect(block.content).toBe(skillBody); expect(TRUNCATION_EXEMPT_TOOLS.has("skill_load")).toBe(true); }); test("non-exempt tool result above threshold is still truncated when paired with a tool_use", () => { // Control for the exemption: same shape as the skill_load case, but a tool // that is NOT exempt must still be truncated. const toolUseId = "tool_bash_1"; const longContent = "B".repeat(THRESHOLD_CHARS + 5_000); const messages: Message[] = [ { role: "assistant", content: [ { type: "tool_use" as const, id: toolUseId, name: "bash", input: { command: "cat big.log" }, }, ], }, { role: "user", content: [makeToolResult(longContent, toolUseId)] }, ]; const { messages: result, truncatedCount } = postTurnTruncateToolResults( messages, { conversationDir: convDir }, ); expect(truncatedCount).toBe(1); const block = result[1].content[0] as { type: "tool_result"; content: string; }; expect(block.content).toContain(TRUNCATION_MARKER); }); test("web_fetch result above threshold IS truncated post-turn (result-time exemption does not extend here)", () => { // web_fetch is exempt from the result-time spool pass so the model reads // the window it sized during the turn; at turn end the consumed page text // is one-off data and must still be stubbed. const toolUseId = "tool_web_fetch_1"; const pageText = "W".repeat(THRESHOLD_CHARS + 5_000); const messages: Message[] = [ { role: "assistant", content: [ { type: "tool_use" as const, id: toolUseId, name: "web_fetch", input: { url: "https://example.com/article" }, }, ], }, { role: "user", content: [makeToolResult(pageText, toolUseId)] }, ]; const { messages: result, truncatedCount } = postTurnTruncateToolResults( messages, { conversationDir: convDir }, ); expect(truncatedCount).toBe(1); const block = result[1].content[0] as { type: "tool_result"; content: string; }; expect(block.content).toContain(TRUNCATION_MARKER); expect(TRUNCATION_EXEMPT_TOOLS.has("web_fetch")).toBe(false); }); test("file path is deterministic for the same toolUseId", () => { const id = "tool_use_deterministic"; const path1 = getToolResultFilePath("/some/dir", id); const path2 = getToolResultFilePath("/some/dir", id); expect(path1).toBe(path2); // Different IDs produce different paths. const path3 = getToolResultFilePath("/some/dir", "tool_use_other"); expect(path3).not.toBe(path1); }); }); describe("derefToolResultReReads", () => { function makeToolUse( id: string, name: string, input: Record, ): ContentBlock { return { type: "tool_use" as const, id, name, input }; } test("file_read of .tool-results/ path: tool_result content replaced with REREAD_STUB", () => { const toolUseId = "tu_reread_1"; const messages: Message[] = [ { role: "assistant", content: [ makeToolUse(toolUseId, "file_read", { path: `/home/user/.vellum/workspace/conversations/abc/${TOOL_RESULT_DIR}/abc123.txt`, }), ], }, { role: "user", content: [makeToolResult("full file contents here", toolUseId)], }, ]; const { messages: result, dereferencedCount } = derefToolResultReReads(messages); expect(dereferencedCount).toBe(1); const block = result[1].content[0] as { type: "tool_result"; content: string; }; expect(block.content).toBe(REREAD_STUB); }); test("host_file_read of .tool-results/ path: tool_result content replaced with REREAD_STUB", () => { const toolUseId = "tu_host_reread"; const messages: Message[] = [ { role: "assistant", content: [ makeToolUse(toolUseId, "host_file_read", { path: `/home/user/.local/share/vellum/conversations/abc/${TOOL_RESULT_DIR}/abc123.txt`, }), ], }, { role: "user", content: [makeToolResult("full host file contents here", toolUseId)], }, ]; const { messages: result, dereferencedCount } = derefToolResultReReads(messages); expect(dereferencedCount).toBe(1); const block = result[1].content[0] as { type: "tool_result"; content: string; }; expect(block.content).toBe(REREAD_STUB); }); test("file_read of normal path: tool_result unchanged", () => { const toolUseId = "tu_normal_read"; const originalContent = "some file contents"; const messages: Message[] = [ { role: "assistant", content: [ makeToolUse(toolUseId, "file_read", { path: "src/foo.ts", }), ], }, { role: "user", content: [makeToolResult(originalContent, toolUseId)], }, ]; const { messages: result, dereferencedCount } = derefToolResultReReads(messages); expect(dereferencedCount).toBe(0); expect(result).toBe(messages); // same reference — no copy const block = result[1].content[0] as { type: "tool_result"; content: string; }; expect(block.content).toBe(originalContent); }); test("non-file_read tool: tool_result unchanged even if output mentions .tool-results/", () => { const toolUseId = "tu_bash"; const outputMentioningDir = `Found file at /home/user/${TOOL_RESULT_DIR}/abc.txt`; const messages: Message[] = [ { role: "assistant", content: [ makeToolUse(toolUseId, "bash", { command: "ls", }), ], }, { role: "user", content: [makeToolResult(outputMentioningDir, toolUseId)], }, ]; const { messages: result, dereferencedCount } = derefToolResultReReads(messages); expect(dereferencedCount).toBe(0); expect(result).toBe(messages); const block = result[1].content[0] as { type: "tool_result"; content: string; }; expect(block.content).toBe(outputMentioningDir); }); test("multiple re-reads in one turn: each deduplicated independently", () => { const tu1 = "tu_multi_1"; const tu2 = "tu_multi_2"; const tuNormal = "tu_multi_normal"; const messages: Message[] = [ { role: "assistant", content: [ makeToolUse(tu1, "file_read", { path: `/workspace/conv/${TOOL_RESULT_DIR}/aaa.txt`, }), makeToolUse(tu2, "file_read", { path: `/workspace/conv/${TOOL_RESULT_DIR}/bbb.txt`, }), makeToolUse(tuNormal, "file_read", { path: "src/bar.ts", }), ], }, { role: "user", content: [ makeToolResult("re-read content 1", tu1), makeToolResult("re-read content 2", tu2), makeToolResult("normal read content", tuNormal), ], }, ]; const { messages: result, dereferencedCount } = derefToolResultReReads(messages); expect(dereferencedCount).toBe(2); const b0 = result[1].content[0] as { type: "tool_result"; content: string }; const b1 = result[1].content[1] as { type: "tool_result"; content: string }; const b2 = result[1].content[2] as { type: "tool_result"; content: string }; expect(b0.content).toBe(REREAD_STUB); expect(b1.content).toBe(REREAD_STUB); expect(b2.content).toBe("normal read content"); // unchanged }); }); function hasOrphanedSurrogate(str: string): boolean { for (let i = 0; i < str.length; i++) { const code = str.charCodeAt(i); if (code >= 0xd800 && code <= 0xdbff) { const next = i + 1 < str.length ? str.charCodeAt(i + 1) : 0; if (next < 0xdc00 || next > 0xdfff) { return true; } i++; } else if (code >= 0xdc00 && code <= 0xdfff) { return true; } } return false; } describe("buildTruncatedContent on fenced results", () => { const FENCED = wrapUntrustedContent("z".repeat(50_000), { source: "web", sourceDetail: "https://example.com/page", maxChars: Number.MAX_SAFE_INTEGER, }); test("keeps the recovery instruction outside the fence", () => { const result = buildTruncatedContent(FENCED, "/tmp/full.txt"); // The marker is the daemon's own instruction. The model is told never // to act on instructions inside ``, so a marker // spliced in there is a recovery signal it must ignore. const closeIndex = result.lastIndexOf(""); expect(closeIndex).toBeGreaterThan(-1); expect(result.indexOf(TRUNCATION_MARKER)).toBeGreaterThan(closeIndex); expect(result).toContain("use file_read to view"); }); test("truncates the fenced data and leaves the envelope well-formed", () => { const result = buildTruncatedContent(FENCED, "/tmp/full.txt"); expect(result.length).toBeLessThan(FENCED.length); const envelope = parseExternalContentEnvelope( result.slice(0, result.lastIndexOf("") + 19), ); expect(envelope).not.toBeNull(); expect(envelope!.source).toBe("web"); expect(envelope!.origin).toBe("https://example.com/page"); expect(envelope!.content).toContain("content omitted"); }); test("stays eligible for the idempotency guard", () => { // `isTruncationEligible` skips anything already carrying the marker; // moving the marker outside the fence must not break that. const result = buildTruncatedContent(FENCED, "/tmp/full.txt"); expect(result).toContain(TRUNCATION_MARKER); }); test("unfenced results keep the marker inline", () => { const result = buildTruncatedContent("z".repeat(50_000), "/tmp/full.txt"); expect(result).toContain(TRUNCATION_MARKER); expect(result).not.toContain(""); }); test("a cut through an embedded envelope still leaves the marker outside", () => { // Mixed result shape: tool-owned lines wrapped around embedded // envelopes, as `browser_navigate` returns. The prefix cut lands // inside the first envelope and the suffix cut inside the second. const mixed = [ "Final URL: https://example.com/login", wrapUntrustedContent("t".repeat(20_000), { source: "web", maxChars: Number.MAX_SAFE_INTEGER, }), "Handle this by interacting with the login form:", wrapUntrustedContent("f".repeat(20_000), { source: "web", maxChars: Number.MAX_SAFE_INTEGER, }), ].join("\n"); const result = buildTruncatedContent(mixed, "/tmp/full.txt"); // Every fence in the stub is balanced... const opens = result.match(/]*>/g)?.length ?? 0; const closes = result.split("").length - 1; expect(opens).toBe(closes); // ...and the marker sits between a closed fence and the next open one, // so the model is never told to ignore its only recovery path. const markerIndex = result.indexOf(TRUNCATION_MARKER); expect(markerIndex).toBeGreaterThan(-1); const before = result.slice(0, markerIndex); const opensBefore = before.match(/]*>/g)?.length ?? 0; const closesBefore = before.split("").length - 1; expect(opensBefore).toBe(closesBefore); }); test("page-authored fence tags cannot skew the repair", () => { // Page text carrying a literal opener would make the repair // mis-count and leave the real wrapper open around the marker. It // never reaches the repair because wrapping escapes both tag forms. const forged = `${"p".repeat(9_000)}${"q".repeat(9_000)}`; const mixed = [ "Final URL: https://example.com/login", wrapUntrustedContent(forged, { source: "web", maxChars: Number.MAX_SAFE_INTEGER, }), "Handle this by interacting with the login form:", ].join("\n"); const result = buildTruncatedContent(mixed, "/tmp/full.txt"); const markerIndex = result.indexOf(TRUNCATION_MARKER); const before = result.slice(0, markerIndex); const opensBefore = before.match(/]*>/g)?.length ?? 0; const closesBefore = before.split("").length - 1; expect(opensBefore).toBe(closesBefore); }); }); describe("buildTruncatedContent surrogate-pair safety", () => { const EMOJI = "\uD83C\uDF89"; const half = Math.floor(TARGET_CHARS / 2); test("does not orphan a surrogate pair at the prefix cut boundary", () => { // Put the emoji so its high surrogate lands exactly at position half - 1. // The naive slice(0, half) would cut the pair in half. const prefix = "a".repeat(half - 1); const filler = "b".repeat(10_000); const original = prefix + EMOJI + filler; const result = buildTruncatedContent(original, "/tmp/fake"); expect(hasOrphanedSurrogate(result)).toBe(false); expect(() => JSON.stringify(result)).not.toThrow(); }); test("does not orphan a surrogate pair at the suffix cut boundary", () => { // Put the emoji so its low surrogate lands exactly at position // original.length - half (the start of the suffix slice). Naive // slice(-half) would start mid-pair and leave a lone low surrogate. const head = "a".repeat(10_000); const suffixTail = "b".repeat(half - 1); // head + EMOJI (2 code units) + suffixTail has length // 10_000 + 2 + (half - 1). The suffix starts at length - half, which // equals 10_000 + 2 + (half - 1) - half = 10_001. That lands on the // low surrogate of the emoji (emoji starts at position 10_000, high at // 10_000, low at 10_001). Exactly the orphan case. const original = head + EMOJI + suffixTail; const result = buildTruncatedContent(original, "/tmp/fake"); expect(hasOrphanedSurrogate(result)).toBe(false); expect(() => JSON.stringify(result)).not.toThrow(); }); });