|
|
@@ -0,0 +1,159 @@
|
|
|
+import { describe, test, expect, afterAll } from "bun:test"
|
|
|
+import { Truncate } from "../../src/tool/truncation"
|
|
|
+import { Identifier } from "../../src/id/id"
|
|
|
+import fs from "fs/promises"
|
|
|
+import path from "path"
|
|
|
+
|
|
|
+const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
|
|
|
+
|
|
|
+describe("Truncate", () => {
|
|
|
+ describe("output", () => {
|
|
|
+ test("truncates large json file by bytes", async () => {
|
|
|
+ const content = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text()
|
|
|
+ const result = await Truncate.output(content)
|
|
|
+
|
|
|
+ expect(result.truncated).toBe(true)
|
|
|
+ expect(result.content).toContain("truncated...")
|
|
|
+ if (result.truncated) expect(result.outputPath).toBeDefined()
|
|
|
+ })
|
|
|
+
|
|
|
+ test("returns content unchanged when under limits", async () => {
|
|
|
+ const content = "line1\nline2\nline3"
|
|
|
+ const result = await Truncate.output(content)
|
|
|
+
|
|
|
+ expect(result.truncated).toBe(false)
|
|
|
+ expect(result.content).toBe(content)
|
|
|
+ })
|
|
|
+
|
|
|
+ test("truncates by line count", async () => {
|
|
|
+ const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
|
|
|
+ const result = await Truncate.output(lines, { maxLines: 10 })
|
|
|
+
|
|
|
+ expect(result.truncated).toBe(true)
|
|
|
+ expect(result.content).toContain("...90 lines truncated...")
|
|
|
+ })
|
|
|
+
|
|
|
+ test("truncates by byte count", async () => {
|
|
|
+ const content = "a".repeat(1000)
|
|
|
+ const result = await Truncate.output(content, { maxBytes: 100 })
|
|
|
+
|
|
|
+ expect(result.truncated).toBe(true)
|
|
|
+ expect(result.content).toContain("truncated...")
|
|
|
+ })
|
|
|
+
|
|
|
+ test("truncates from head by default", async () => {
|
|
|
+ const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
|
|
|
+ const result = await Truncate.output(lines, { maxLines: 3 })
|
|
|
+
|
|
|
+ expect(result.truncated).toBe(true)
|
|
|
+ expect(result.content).toContain("line0")
|
|
|
+ expect(result.content).toContain("line1")
|
|
|
+ expect(result.content).toContain("line2")
|
|
|
+ expect(result.content).not.toContain("line9")
|
|
|
+ })
|
|
|
+
|
|
|
+ test("truncates from tail when direction is tail", async () => {
|
|
|
+ const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
|
|
|
+ const result = await Truncate.output(lines, { maxLines: 3, direction: "tail" })
|
|
|
+
|
|
|
+ expect(result.truncated).toBe(true)
|
|
|
+ expect(result.content).toContain("line7")
|
|
|
+ expect(result.content).toContain("line8")
|
|
|
+ expect(result.content).toContain("line9")
|
|
|
+ expect(result.content).not.toContain("line0")
|
|
|
+ })
|
|
|
+
|
|
|
+ test("uses default MAX_LINES and MAX_BYTES", () => {
|
|
|
+ expect(Truncate.MAX_LINES).toBe(2000)
|
|
|
+ expect(Truncate.MAX_BYTES).toBe(50 * 1024)
|
|
|
+ })
|
|
|
+
|
|
|
+ test("large single-line file truncates with byte message", async () => {
|
|
|
+ const content = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text()
|
|
|
+ const result = await Truncate.output(content)
|
|
|
+
|
|
|
+ expect(result.truncated).toBe(true)
|
|
|
+ expect(result.content).toContain("bytes truncated...")
|
|
|
+ expect(Buffer.byteLength(content, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES)
|
|
|
+ })
|
|
|
+
|
|
|
+ test("writes full output to file when truncated", async () => {
|
|
|
+ const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
|
|
|
+ const result = await Truncate.output(lines, { maxLines: 10 })
|
|
|
+
|
|
|
+ expect(result.truncated).toBe(true)
|
|
|
+ expect(result.content).toContain("The tool call succeeded but the output was truncated")
|
|
|
+ expect(result.content).toContain("Grep")
|
|
|
+ if (!result.truncated) throw new Error("expected truncated")
|
|
|
+ expect(result.outputPath).toBeDefined()
|
|
|
+ expect(result.outputPath).toContain("tool_")
|
|
|
+
|
|
|
+ const written = await Bun.file(result.outputPath).text()
|
|
|
+ expect(written).toBe(lines)
|
|
|
+ })
|
|
|
+
|
|
|
+ test("suggests Task tool when agent has task permission", async () => {
|
|
|
+ const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
|
|
|
+ const agent = { permission: [{ permission: "task", pattern: "*", action: "allow" as const }] }
|
|
|
+ const result = await Truncate.output(lines, { maxLines: 10 }, agent as any)
|
|
|
+
|
|
|
+ expect(result.truncated).toBe(true)
|
|
|
+ expect(result.content).toContain("Grep")
|
|
|
+ expect(result.content).toContain("Task tool")
|
|
|
+ })
|
|
|
+
|
|
|
+ test("omits Task tool hint when agent lacks task permission", async () => {
|
|
|
+ const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
|
|
|
+ const agent = { permission: [{ permission: "task", pattern: "*", action: "deny" as const }] }
|
|
|
+ const result = await Truncate.output(lines, { maxLines: 10 }, agent as any)
|
|
|
+
|
|
|
+ expect(result.truncated).toBe(true)
|
|
|
+ expect(result.content).toContain("Grep")
|
|
|
+ expect(result.content).not.toContain("Task tool")
|
|
|
+ })
|
|
|
+
|
|
|
+ test("does not write file when not truncated", async () => {
|
|
|
+ const content = "short content"
|
|
|
+ const result = await Truncate.output(content)
|
|
|
+
|
|
|
+ expect(result.truncated).toBe(false)
|
|
|
+ if (result.truncated) throw new Error("expected not truncated")
|
|
|
+ expect("outputPath" in result).toBe(false)
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ describe("cleanup", () => {
|
|
|
+ const DAY_MS = 24 * 60 * 60 * 1000
|
|
|
+ let oldFile: string
|
|
|
+ let recentFile: string
|
|
|
+
|
|
|
+ afterAll(async () => {
|
|
|
+ await fs.unlink(oldFile).catch(() => {})
|
|
|
+ await fs.unlink(recentFile).catch(() => {})
|
|
|
+ })
|
|
|
+
|
|
|
+ test("deletes files older than 7 days and preserves recent files", async () => {
|
|
|
+ await fs.mkdir(Truncate.DIR, { recursive: true })
|
|
|
+
|
|
|
+ // Create an old file (10 days ago)
|
|
|
+ const oldTimestamp = Date.now() - 10 * DAY_MS
|
|
|
+ const oldId = Identifier.create("tool", false, oldTimestamp)
|
|
|
+ oldFile = path.join(Truncate.DIR, oldId)
|
|
|
+ await Bun.write(Bun.file(oldFile), "old content")
|
|
|
+
|
|
|
+ // Create a recent file (3 days ago)
|
|
|
+ const recentTimestamp = Date.now() - 3 * DAY_MS
|
|
|
+ const recentId = Identifier.create("tool", false, recentTimestamp)
|
|
|
+ recentFile = path.join(Truncate.DIR, recentId)
|
|
|
+ await Bun.write(Bun.file(recentFile), "recent content")
|
|
|
+
|
|
|
+ await Truncate.cleanup()
|
|
|
+
|
|
|
+ // Old file should be deleted
|
|
|
+ expect(await Bun.file(oldFile).exists()).toBe(false)
|
|
|
+
|
|
|
+ // Recent file should still exist
|
|
|
+ expect(await Bun.file(recentFile).exists()).toBe(true)
|
|
|
+ })
|
|
|
+ })
|
|
|
+})
|