feat: File based long tool return (#488)

This commit is contained in:
Kevin Lin
2026-01-08 06:15:51 +08:00
committed by GitHub
parent 4c59ca45ba
commit d0837e3536
12 changed files with 929 additions and 44 deletions

View File

@@ -0,0 +1,117 @@
import { afterEach, describe, expect, test } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
import { bash } from "../../tools/impl/Bash";
import { grep } from "../../tools/impl/Grep";
import { getOverflowDirectory } from "../../tools/impl/overflow";
describe("overflow integration tests", () => {
const testWorkingDir = process.cwd();
let overflowDir: string;
afterEach(() => {
overflowDir = getOverflowDirectory(testWorkingDir);
// Clean up test files
if (fs.existsSync(overflowDir)) {
const files = fs.readdirSync(overflowDir);
for (const file of files) {
fs.unlinkSync(path.join(overflowDir, file));
}
}
});
describe("Bash tool with overflow", () => {
test("creates overflow file for long output", async () => {
// Set USER_CWD for the test
process.env.USER_CWD = testWorkingDir;
// Generate a large output (more than 30K characters)
const command =
'for i in {1..2000}; do echo "Line $i with some padding text to make it longer"; done';
const result = await bash({ command });
// Check that output was truncated
expect(result.status).toBe("success");
expect(result.content[0]?.text).toContain("Output truncated");
expect(result.content[0]?.text).toContain("Full output written to:");
// Extract overflow path from the output
const match = result.content[0]?.text?.match(
/Full output written to: (.+\.txt)/,
);
expect(match).toBeDefined();
if (match?.[1]) {
const overflowPath = match[1];
expect(fs.existsSync(overflowPath)).toBe(true);
// Verify the overflow file contains the full output
const fullContent = fs.readFileSync(overflowPath, "utf-8");
expect(fullContent).toContain("Line 1 with some padding");
expect(fullContent).toContain("Line 2000 with some padding");
expect(fullContent.length).toBeGreaterThan(30_000);
}
});
test("no overflow file for short output", async () => {
process.env.USER_CWD = testWorkingDir;
const command = "echo 'Short output'";
const result = await bash({ command });
expect(result.status).toBe("success");
expect(result.content[0]?.text).not.toContain("Output truncated");
expect(result.content[0]?.text).not.toContain("Full output written to");
});
});
describe("Grep tool with overflow", () => {
test("creates overflow file for large search results", async () => {
process.env.USER_CWD = testWorkingDir;
// Search for a common pattern that will have many results
const result = await grep({
pattern: "test",
path: "src/tests",
output_mode: "files_with_matches",
});
// If we have enough results to trigger truncation
if (
result.output.includes("Output truncated") &&
result.output.includes("Full output written to")
) {
const match = result.output.match(/Full output written to: (.+\.txt)/);
expect(match).toBeDefined();
if (match?.[1]) {
const overflowPath = match[1];
expect(fs.existsSync(overflowPath)).toBe(true);
const fullContent = fs.readFileSync(overflowPath, "utf-8");
expect(fullContent.length).toBeGreaterThan(0);
}
}
});
});
describe("Middle truncation verification", () => {
test("shows beginning and end of output", async () => {
process.env.USER_CWD = testWorkingDir;
// Generate output with distinctive beginning and end
const command =
'echo "START_MARKER"; for i in {1..1000}; do echo "Middle line $i"; done; echo "END_MARKER"';
const result = await bash({ command });
if (result.content[0]?.text?.includes("Output truncated")) {
// Should contain both START and END markers due to middle truncation
expect(result.content[0].text).toContain("START_MARKER");
expect(result.content[0].text).toContain("END_MARKER");
expect(result.content[0].text).toContain("characters omitted");
}
});
});
});

View File

@@ -0,0 +1,205 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
cleanupOldOverflowFiles,
ensureOverflowDirectory,
getOverflowDirectory,
getOverflowStats,
OVERFLOW_CONFIG,
writeOverflowFile,
} from "../../tools/impl/overflow";
describe("overflow utilities", () => {
const testWorkingDir = "/test/project/path";
let overflowDir: string;
beforeEach(() => {
overflowDir = getOverflowDirectory(testWorkingDir);
});
afterEach(() => {
// Clean up test files
if (fs.existsSync(overflowDir)) {
const files = fs.readdirSync(overflowDir);
for (const file of files) {
fs.unlinkSync(path.join(overflowDir, file));
}
// Try to remove the directory (will fail if not empty)
try {
fs.rmdirSync(overflowDir);
} catch {
// Directory not empty, that's OK
}
}
});
describe("OVERFLOW_CONFIG", () => {
test("has expected default values", () => {
expect(OVERFLOW_CONFIG.ENABLED).toBeDefined();
expect(OVERFLOW_CONFIG.MIDDLE_TRUNCATE).toBeDefined();
});
});
describe("getOverflowDirectory", () => {
test("generates consistent directory path", () => {
const dir1 = getOverflowDirectory(testWorkingDir);
const dir2 = getOverflowDirectory(testWorkingDir);
expect(dir1).toBe(dir2);
});
test("creates path under ~/.letta", () => {
const dir = getOverflowDirectory(testWorkingDir);
const homeDir = os.homedir();
expect(dir).toContain(path.join(homeDir, ".letta"));
});
test("sanitizes working directory path", () => {
const dir = getOverflowDirectory("/path/with spaces/and:colons");
expect(dir).not.toContain(" ");
expect(dir).not.toContain(":");
expect(dir).toContain("path_with_spaces_and_colons");
});
});
describe("ensureOverflowDirectory", () => {
test("creates directory if it doesn't exist", () => {
const dir = ensureOverflowDirectory(testWorkingDir);
expect(fs.existsSync(dir)).toBe(true);
expect(fs.statSync(dir).isDirectory()).toBe(true);
});
test("returns existing directory without error", () => {
const dir1 = ensureOverflowDirectory(testWorkingDir);
const dir2 = ensureOverflowDirectory(testWorkingDir);
expect(dir1).toBe(dir2);
expect(fs.existsSync(dir1)).toBe(true);
});
});
describe("writeOverflowFile", () => {
test("writes content to a file", () => {
const content = "Test content for overflow file";
const filePath = writeOverflowFile(content, testWorkingDir, "TestTool");
expect(fs.existsSync(filePath)).toBe(true);
expect(fs.readFileSync(filePath, "utf-8")).toBe(content);
});
test("generates unique filenames", () => {
const content = "Test content";
const file1 = writeOverflowFile(content, testWorkingDir, "TestTool");
const file2 = writeOverflowFile(content, testWorkingDir, "TestTool");
expect(file1).not.toBe(file2);
});
test("includes tool name in filename", () => {
const content = "Test content";
const filePath = writeOverflowFile(
content,
testWorkingDir,
"MyCustomTool",
);
expect(path.basename(filePath)).toContain("mycustomtool");
});
test("handles large content", () => {
const largeContent = "x".repeat(100_000);
const filePath = writeOverflowFile(
largeContent,
testWorkingDir,
"TestTool",
);
expect(fs.existsSync(filePath)).toBe(true);
expect(fs.readFileSync(filePath, "utf-8")).toBe(largeContent);
});
});
describe("cleanupOldOverflowFiles", () => {
test("removes files older than specified age", async () => {
// Create a test file
const content = "Test content";
const filePath = writeOverflowFile(content, testWorkingDir, "TestTool");
// Manually set the file's mtime to be old
const oldTime = Date.now() - 48 * 60 * 60 * 1000; // 48 hours ago
fs.utimesSync(filePath, new Date(oldTime), new Date(oldTime));
// Clean up files older than 24 hours
const deletedCount = cleanupOldOverflowFiles(
testWorkingDir,
24 * 60 * 60 * 1000,
);
expect(deletedCount).toBe(1);
expect(fs.existsSync(filePath)).toBe(false);
});
test("preserves recent files", () => {
const content = "Test content";
const filePath = writeOverflowFile(content, testWorkingDir, "TestTool");
// Clean up files older than 24 hours (file is recent)
const deletedCount = cleanupOldOverflowFiles(
testWorkingDir,
24 * 60 * 60 * 1000,
);
expect(deletedCount).toBe(0);
expect(fs.existsSync(filePath)).toBe(true);
});
test("returns 0 if directory doesn't exist", () => {
const nonExistentDir = "/non/existent/directory";
const deletedCount = cleanupOldOverflowFiles(
nonExistentDir,
24 * 60 * 60 * 1000,
);
expect(deletedCount).toBe(0);
});
});
describe("getOverflowStats", () => {
test("returns correct stats for empty directory", () => {
ensureOverflowDirectory(testWorkingDir);
const stats = getOverflowStats(testWorkingDir);
expect(stats.exists).toBe(true);
expect(stats.fileCount).toBe(0);
expect(stats.totalSize).toBe(0);
});
test("returns correct stats for directory with files", () => {
const content1 = "Test content 1";
const content2 = "Test content 2 is longer";
writeOverflowFile(content1, testWorkingDir, "Tool1");
writeOverflowFile(content2, testWorkingDir, "Tool2");
const stats = getOverflowStats(testWorkingDir);
expect(stats.exists).toBe(true);
expect(stats.fileCount).toBe(2);
expect(stats.totalSize).toBeGreaterThan(0);
});
test("returns correct stats for non-existent directory", () => {
const nonExistentDir = "/non/existent/directory";
const stats = getOverflowStats(nonExistentDir);
expect(stats.exists).toBe(false);
expect(stats.fileCount).toBe(0);
expect(stats.totalSize).toBe(0);
});
});
});

View File

@@ -41,7 +41,7 @@ describe("tool truncation integration tests", () => {
});
const output = result.content[0]?.text || "";
expect(output).toContain("[Output truncated after 30,000 characters");
expect(output).toContain("[Output truncated: showing 30,000");
expect(output.length).toBeLessThan(35000); // Truncated + notice
},
);
@@ -64,7 +64,7 @@ describe("tool truncation integration tests", () => {
});
const output = result.content[0]?.text || "";
expect(output).toContain("[Output truncated after 30,000 characters");
expect(output).toContain("[Output truncated: showing 30,000");
expect(result.status).toBe("error");
},
);
@@ -143,9 +143,7 @@ describe("tool truncation integration tests", () => {
});
expect(result.output.length).toBeLessThanOrEqual(15000); // 10K + notice
expect(result.output).toContain(
"[Output truncated after 10,000 characters",
);
expect(result.output).toContain("[Output truncated: showing 10,000");
});
test("truncates file list exceeding 10K characters", async () => {
@@ -165,9 +163,7 @@ describe("tool truncation integration tests", () => {
expect(result.output.length).toBeLessThanOrEqual(15000);
if (result.output.length > 10000) {
expect(result.output).toContain(
"[Output truncated after 10,000 characters",
);
expect(result.output).toContain("[Output truncated: showing 10,000");
}
});
@@ -196,15 +192,11 @@ describe("tool truncation integration tests", () => {
const result = await glob({ pattern: "**/*.txt", path: testDir });
expect(result.files.length).toBeLessThanOrEqual(
LIMITS.GLOB_MAX_FILES + 1,
); // +1 for notice
expect(result.truncated).toBe(true);
expect(result.totalFiles).toBe(2500);
// Last entry should be the truncation notice
expect(result.files[result.files.length - 1]).toContain(
"showing 2,000 of 2,500 files",
);
// Should contain the truncation notice
const filesString = result.files.join("\n");
expect(filesString).toContain("showing 2,000 of 2,500 files");
},
{ timeout: 15000 },
); // Increased timeout for Windows CI where file creation is slower
@@ -286,7 +278,7 @@ describe("tool truncation integration tests", () => {
expect(outputResult.message.length).toBeLessThan(35000); // 30K + notice
if (outputResult.message.length > 30000) {
expect(outputResult.message).toContain(
"[Output truncated after 30,000 characters",
"[Output truncated: showing 30,000",
);
}
},

View File

@@ -0,0 +1,197 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
import { getOverflowDirectory } from "../../tools/impl/overflow";
import {
truncateArray,
truncateByChars,
truncateByLines,
} from "../../tools/impl/truncation";
describe("truncation with overflow support", () => {
const testWorkingDir = "/test/truncation/path";
let overflowDir: string;
beforeEach(() => {
overflowDir = getOverflowDirectory(testWorkingDir);
});
afterEach(() => {
// Clean up test files
if (fs.existsSync(overflowDir)) {
const files = fs.readdirSync(overflowDir);
for (const file of files) {
fs.unlinkSync(path.join(overflowDir, file));
}
}
});
describe("truncateByChars with overflow", () => {
test("writes overflow file when content exceeds limit", () => {
const longText = "a".repeat(2000);
const result = truncateByChars(longText, 1000, "TestTool", {
workingDirectory: testWorkingDir,
toolName: "TestTool",
});
expect(result.wasTruncated).toBe(true);
expect(result.overflowPath).toBeDefined();
if (result.overflowPath) {
expect(fs.existsSync(result.overflowPath)).toBe(true);
expect(fs.readFileSync(result.overflowPath, "utf-8")).toBe(longText);
}
});
test("includes overflow path in truncation notice", () => {
const longText = "x".repeat(2000);
const result = truncateByChars(longText, 1000, "TestTool", {
workingDirectory: testWorkingDir,
toolName: "TestTool",
});
expect(result.content).toContain("Full output written to:");
expect(result.content).toContain(result.overflowPath || "");
});
test("uses middle truncation when enabled", () => {
const text = `${"a".repeat(500)}MIDDLE${"b".repeat(500)}`;
const result = truncateByChars(text, 600, "TestTool", {
workingDirectory: testWorkingDir,
useMiddleTruncation: true,
});
expect(result.wasTruncated).toBe(true);
expect(result.content).toContain("a".repeat(300)); // beginning
expect(result.content).toContain("b".repeat(300)); // end
expect(result.content).toContain("characters omitted");
// "MIDDLE" should be omitted
expect(result.content.split("[")[0]).not.toContain("MIDDLE");
});
test("uses post truncation when middle truncation disabled", () => {
const text = `${"a".repeat(500)}END`;
const result = truncateByChars(text, 300, "TestTool", {
workingDirectory: testWorkingDir,
useMiddleTruncation: false,
});
expect(result.wasTruncated).toBe(true);
expect(result.content).toContain("a".repeat(300));
expect(result.content.split("[")[0]).not.toContain("END");
});
test("does not create overflow file when under limit", () => {
const shortText = "short text";
const result = truncateByChars(shortText, 1000, "TestTool", {
workingDirectory: testWorkingDir,
toolName: "TestTool",
});
expect(result.wasTruncated).toBe(false);
expect(result.overflowPath).toBeUndefined();
});
});
describe("truncateByLines with overflow", () => {
test("writes overflow file when lines exceed limit", () => {
const lines = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`);
const text = lines.join("\n");
const result = truncateByLines(text, 50, undefined, "TestTool", {
workingDirectory: testWorkingDir,
toolName: "TestTool",
});
expect(result.wasTruncated).toBe(true);
expect(result.overflowPath).toBeDefined();
if (result.overflowPath) {
expect(fs.existsSync(result.overflowPath)).toBe(true);
expect(fs.readFileSync(result.overflowPath, "utf-8")).toBe(text);
}
});
test("uses middle truncation for lines when enabled", () => {
const lines = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`);
const text = lines.join("\n");
const result = truncateByLines(text, 50, undefined, "TestTool", {
workingDirectory: testWorkingDir,
useMiddleTruncation: true,
});
expect(result.wasTruncated).toBe(true);
expect(result.content).toContain("Line 1"); // beginning
expect(result.content).toContain("Line 25"); // end of first half
expect(result.content).toContain("Line 76"); // beginning of second half
expect(result.content).toContain("Line 100"); // end
expect(result.content).toContain("lines omitted");
});
test("includes overflow path in truncation notice", () => {
const lines = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`);
const text = lines.join("\n");
const result = truncateByLines(text, 50, undefined, "TestTool", {
workingDirectory: testWorkingDir,
toolName: "TestTool",
});
expect(result.content).toContain("Full output written to:");
expect(result.content).toContain(result.overflowPath || "");
});
});
describe("truncateArray with overflow", () => {
test("writes overflow file when items exceed limit", () => {
const items = Array.from({ length: 100 }, (_, i) => `item${i + 1}`);
const formatter = (arr: string[]) => arr.join("\n");
const result = truncateArray(items, 50, formatter, "items", "TestTool", {
workingDirectory: testWorkingDir,
toolName: "TestTool",
});
expect(result.wasTruncated).toBe(true);
expect(result.overflowPath).toBeDefined();
if (result.overflowPath) {
expect(fs.existsSync(result.overflowPath)).toBe(true);
const savedContent = fs.readFileSync(result.overflowPath, "utf-8");
expect(savedContent).toContain("item1");
expect(savedContent).toContain("item100");
}
});
test("uses middle truncation for arrays when enabled", () => {
const items = Array.from({ length: 100 }, (_, i) => `item${i + 1}`);
const formatter = (arr: string[]) => arr.join("\n");
const result = truncateArray(items, 50, formatter, "items", "TestTool", {
workingDirectory: testWorkingDir,
useMiddleTruncation: true,
});
expect(result.wasTruncated).toBe(true);
expect(result.content).toContain("item1"); // beginning
expect(result.content).toContain("item25"); // end of first half
expect(result.content).toContain("item76"); // beginning of second half
expect(result.content).toContain("item100"); // end
expect(result.content).toContain("omitted from middle");
});
test("includes overflow path in truncation notice", () => {
const items = Array.from({ length: 100 }, (_, i) => `item${i + 1}`);
const formatter = (arr: string[]) => arr.join("\n");
const result = truncateArray(items, 50, formatter, "items", "TestTool", {
workingDirectory: testWorkingDir,
toolName: "TestTool",
});
expect(result.content).toContain("Full output written to:");
expect(result.content).toContain(result.overflowPath || "");
});
});
});

View File

@@ -21,9 +21,11 @@ describe("truncation utilities", () => {
const result = truncateByChars(text, 500, "Test");
expect(result.wasTruncated).toBe(true);
expect(result.content).toContain("a".repeat(500));
// With middle truncation, we should see beginning and end
expect(result.content).toContain("a".repeat(250)); // beginning
expect(result.content).toContain("characters omitted");
expect(result.content).toContain(
"[Output truncated after 500 characters: exceeded limit.]",
"[Output truncated: showing 500 of 1,000 characters.]",
);
expect(result.content.length).toBeGreaterThan(500); // Due to notice
});
@@ -62,10 +64,13 @@ describe("truncation utilities", () => {
expect(result.wasTruncated).toBe(true);
expect(result.originalLineCount).toBe(100);
expect(result.linesShown).toBe(50);
// With middle truncation, we get beginning + marker + end = 51 lines shown
expect(result.linesShown).toBe(51);
expect(result.content).toContain("Line 1");
expect(result.content).toContain("Line 50");
expect(result.content).not.toContain("Line 51");
expect(result.content).toContain("Line 25"); // end of first half
expect(result.content).toContain("lines omitted");
expect(result.content).toContain("Line 76"); // beginning of second half
expect(result.content).toContain("Line 100");
expect(result.content).toContain("showing 50 of 100 lines");
});
@@ -92,7 +97,8 @@ describe("truncation utilities", () => {
expect(result.wasTruncated).toBe(true);
expect(result.originalLineCount).toBe(100);
expect(result.linesShown).toBe(50);
// With middle truncation, we get beginning + marker + end = 51 lines shown
expect(result.linesShown).toBe(51);
expect(result.content).toContain("showing 50 of 100 lines");
expect(result.content).toContain(
"Some lines exceeded 1,000 characters and were truncated",
@@ -125,9 +131,12 @@ describe("truncation utilities", () => {
expect(result.wasTruncated).toBe(true);
expect(result.content).toContain("item1");
expect(result.content).toContain("item50");
expect(result.content).not.toContain("item51");
// With middle truncation, we show first 25 and last 25
expect(result.content).toContain("item25");
expect(result.content).toContain("item76");
expect(result.content).toContain("item100");
expect(result.content).toContain("showing 50 of 100 items");
expect(result.content).toContain("omitted from middle");
});
test("exactly at limit does not truncate", () => {