truncation.test.ts 6.1 KB

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