truncation.test.ts 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. import { describe, test, expect, afterAll } from "bun:test"
  2. import { Truncate } from "../../src/tool/truncation"
  3. import { Identifier } from "../../src/id/id"
  4. import { Filesystem } from "../../src/util/filesystem"
  5. import fs from "fs/promises"
  6. import path from "path"
  7. const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
  8. describe("Truncate", () => {
  9. describe("output", () => {
  10. test("truncates large json file by bytes", async () => {
  11. const content = await Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json"))
  12. const result = await Truncate.output(content)
  13. expect(result.truncated).toBe(true)
  14. expect(result.content).toContain("truncated...")
  15. if (result.truncated) expect(result.outputPath).toBeDefined()
  16. })
  17. test("returns content unchanged when under limits", async () => {
  18. const content = "line1\nline2\nline3"
  19. const result = await Truncate.output(content)
  20. expect(result.truncated).toBe(false)
  21. expect(result.content).toBe(content)
  22. })
  23. test("truncates by line count", async () => {
  24. const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
  25. const result = await Truncate.output(lines, { maxLines: 10 })
  26. expect(result.truncated).toBe(true)
  27. expect(result.content).toContain("...90 lines truncated...")
  28. })
  29. test("truncates by byte count", async () => {
  30. const content = "a".repeat(1000)
  31. const result = await Truncate.output(content, { maxBytes: 100 })
  32. expect(result.truncated).toBe(true)
  33. expect(result.content).toContain("truncated...")
  34. })
  35. test("truncates from head by default", async () => {
  36. const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
  37. const result = await Truncate.output(lines, { maxLines: 3 })
  38. expect(result.truncated).toBe(true)
  39. expect(result.content).toContain("line0")
  40. expect(result.content).toContain("line1")
  41. expect(result.content).toContain("line2")
  42. expect(result.content).not.toContain("line9")
  43. })
  44. test("truncates from tail when direction is tail", async () => {
  45. const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
  46. const result = await Truncate.output(lines, { maxLines: 3, direction: "tail" })
  47. expect(result.truncated).toBe(true)
  48. expect(result.content).toContain("line7")
  49. expect(result.content).toContain("line8")
  50. expect(result.content).toContain("line9")
  51. expect(result.content).not.toContain("line0")
  52. })
  53. test("uses default MAX_LINES and MAX_BYTES", () => {
  54. expect(Truncate.MAX_LINES).toBe(2000)
  55. expect(Truncate.MAX_BYTES).toBe(50 * 1024)
  56. })
  57. test("large single-line file truncates with byte message", async () => {
  58. const content = await Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json"))
  59. const result = await Truncate.output(content)
  60. expect(result.truncated).toBe(true)
  61. expect(result.content).toContain("bytes truncated...")
  62. expect(Buffer.byteLength(content, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES)
  63. })
  64. test("writes full output to file when truncated", async () => {
  65. const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
  66. const result = await Truncate.output(lines, { maxLines: 10 })
  67. expect(result.truncated).toBe(true)
  68. expect(result.content).toContain("The tool call succeeded but the output was truncated")
  69. expect(result.content).toContain("Grep")
  70. if (!result.truncated) throw new Error("expected truncated")
  71. expect(result.outputPath).toBeDefined()
  72. expect(result.outputPath).toContain("tool_")
  73. const written = await Filesystem.readText(result.outputPath!)
  74. expect(written).toBe(lines)
  75. })
  76. test("suggests Task tool when agent has task permission", async () => {
  77. const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
  78. const agent = { permission: [{ permission: "task", pattern: "*", action: "allow" as const }] }
  79. const result = await Truncate.output(lines, { maxLines: 10 }, agent as any)
  80. expect(result.truncated).toBe(true)
  81. expect(result.content).toContain("Grep")
  82. expect(result.content).toContain("Task tool")
  83. })
  84. test("omits Task tool hint when agent lacks task permission", async () => {
  85. const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
  86. const agent = { permission: [{ permission: "task", pattern: "*", action: "deny" as const }] }
  87. const result = await Truncate.output(lines, { maxLines: 10 }, agent as any)
  88. expect(result.truncated).toBe(true)
  89. expect(result.content).toContain("Grep")
  90. expect(result.content).not.toContain("Task tool")
  91. })
  92. test("does not write file when not truncated", async () => {
  93. const content = "short content"
  94. const result = await Truncate.output(content)
  95. expect(result.truncated).toBe(false)
  96. if (result.truncated) throw new Error("expected not truncated")
  97. expect("outputPath" in result).toBe(false)
  98. })
  99. })
  100. describe("cleanup", () => {
  101. const DAY_MS = 24 * 60 * 60 * 1000
  102. let oldFile: string
  103. let recentFile: string
  104. afterAll(async () => {
  105. await fs.unlink(oldFile).catch(() => {})
  106. await fs.unlink(recentFile).catch(() => {})
  107. })
  108. test("deletes files older than 7 days and preserves recent files", async () => {
  109. await fs.mkdir(Truncate.DIR, { recursive: true })
  110. // Create an old file (10 days ago)
  111. const oldTimestamp = Date.now() - 10 * DAY_MS
  112. const oldId = Identifier.create("tool", false, oldTimestamp)
  113. oldFile = path.join(Truncate.DIR, oldId)
  114. await Filesystem.write(oldFile, "old content")
  115. // Create a recent file (3 days ago)
  116. const recentTimestamp = Date.now() - 3 * DAY_MS
  117. const recentId = Identifier.create("tool", false, recentTimestamp)
  118. recentFile = path.join(Truncate.DIR, recentId)
  119. await Filesystem.write(recentFile, "recent content")
  120. await Truncate.cleanup()
  121. // Old file should be deleted
  122. expect(await Filesystem.exists(oldFile)).toBe(false)
  123. // Recent file should still exist
  124. expect(await Filesystem.exists(recentFile)).toBe(true)
  125. })
  126. })
  127. })