truncation.test.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. import { describe, test, expect } from "bun:test"
  2. import { NodeFileSystem } from "@effect/platform-node"
  3. import { Effect, FileSystem, Layer } from "effect"
  4. import { Truncate } from "../../src/tool/truncate"
  5. import { Identifier } from "../../src/id/id"
  6. import { Process } from "../../src/util/process"
  7. import { Filesystem } from "../../src/util/filesystem"
  8. import path from "path"
  9. import { testEffect } from "../lib/effect"
  10. import { writeFileStringScoped } from "../lib/filesystem"
  11. const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
  12. const ROOT = path.resolve(import.meta.dir, "..", "..")
  13. const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, NodeFileSystem.layer))
  14. describe("Truncate", () => {
  15. describe("output", () => {
  16. it.live("truncates large json file by bytes", () =>
  17. Effect.gen(function* () {
  18. const svc = yield* Truncate.Service
  19. const content = yield* Effect.promise(() => Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json")))
  20. const result = yield* svc.output(content)
  21. expect(result.truncated).toBe(true)
  22. expect(result.content).toContain("truncated...")
  23. if (result.truncated) expect(result.outputPath).toBeDefined()
  24. }),
  25. )
  26. it.live("returns content unchanged when under limits", () =>
  27. Effect.gen(function* () {
  28. const svc = yield* Truncate.Service
  29. const content = "line1\nline2\nline3"
  30. const result = yield* svc.output(content)
  31. expect(result.truncated).toBe(false)
  32. expect(result.content).toBe(content)
  33. }),
  34. )
  35. it.live("truncates by line count", () =>
  36. Effect.gen(function* () {
  37. const svc = yield* Truncate.Service
  38. const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
  39. const result = yield* svc.output(lines, { maxLines: 10 })
  40. expect(result.truncated).toBe(true)
  41. expect(result.content).toContain("...90 lines truncated...")
  42. }),
  43. )
  44. it.live("truncates by byte count", () =>
  45. Effect.gen(function* () {
  46. const svc = yield* Truncate.Service
  47. const content = "a".repeat(1000)
  48. const result = yield* svc.output(content, { maxBytes: 100 })
  49. expect(result.truncated).toBe(true)
  50. expect(result.content).toContain("truncated...")
  51. }),
  52. )
  53. it.live("truncates from head by default", () =>
  54. Effect.gen(function* () {
  55. const svc = yield* Truncate.Service
  56. const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
  57. const result = yield* svc.output(lines, { maxLines: 3 })
  58. expect(result.truncated).toBe(true)
  59. expect(result.content).toContain("line0")
  60. expect(result.content).toContain("line1")
  61. expect(result.content).toContain("line2")
  62. expect(result.content).not.toContain("line9")
  63. }),
  64. )
  65. it.live("truncates from tail when direction is tail", () =>
  66. Effect.gen(function* () {
  67. const svc = yield* Truncate.Service
  68. const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
  69. const result = yield* svc.output(lines, { maxLines: 3, direction: "tail" })
  70. expect(result.truncated).toBe(true)
  71. expect(result.content).toContain("line7")
  72. expect(result.content).toContain("line8")
  73. expect(result.content).toContain("line9")
  74. expect(result.content).not.toContain("line0")
  75. }),
  76. )
  77. test("uses default MAX_LINES and MAX_BYTES", () => {
  78. expect(Truncate.MAX_LINES).toBe(2000)
  79. expect(Truncate.MAX_BYTES).toBe(50 * 1024)
  80. })
  81. it.live("large single-line file truncates with byte message", () =>
  82. Effect.gen(function* () {
  83. const svc = yield* Truncate.Service
  84. const content = yield* Effect.promise(() => Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json")))
  85. const result = yield* svc.output(content)
  86. expect(result.truncated).toBe(true)
  87. expect(result.content).toContain("bytes truncated...")
  88. expect(Buffer.byteLength(content, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES)
  89. }),
  90. )
  91. it.live("writes full output to file when truncated", () =>
  92. Effect.gen(function* () {
  93. const svc = yield* Truncate.Service
  94. const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
  95. const result = yield* svc.output(lines, { maxLines: 10 })
  96. expect(result.truncated).toBe(true)
  97. expect(result.content).toContain("The tool call succeeded but the output was truncated")
  98. expect(result.content).toContain("Grep")
  99. if (!result.truncated) throw new Error("expected truncated")
  100. expect(result.outputPath).toBeDefined()
  101. expect(result.outputPath).toContain("tool_")
  102. const written = yield* Effect.promise(() => Filesystem.readText(result.outputPath!))
  103. expect(written).toBe(lines)
  104. }),
  105. )
  106. it.live("suggests Task tool when agent has task permission", () =>
  107. Effect.gen(function* () {
  108. const svc = yield* Truncate.Service
  109. const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
  110. const agent = { permission: [{ permission: "task", pattern: "*", action: "allow" as const }] }
  111. const result = yield* svc.output(lines, { maxLines: 10 }, agent as any)
  112. expect(result.truncated).toBe(true)
  113. expect(result.content).toContain("Grep")
  114. expect(result.content).toContain("Task tool")
  115. }),
  116. )
  117. it.live("omits Task tool hint when agent lacks task permission", () =>
  118. Effect.gen(function* () {
  119. const svc = yield* Truncate.Service
  120. const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
  121. const agent = { permission: [{ permission: "task", pattern: "*", action: "deny" as const }] }
  122. const result = yield* svc.output(lines, { maxLines: 10 }, agent as any)
  123. expect(result.truncated).toBe(true)
  124. expect(result.content).toContain("Grep")
  125. expect(result.content).not.toContain("Task tool")
  126. }),
  127. )
  128. it.live("does not write file when not truncated", () =>
  129. Effect.gen(function* () {
  130. const svc = yield* Truncate.Service
  131. const content = "short content"
  132. const result = yield* svc.output(content)
  133. expect(result.truncated).toBe(false)
  134. if (result.truncated) throw new Error("expected not truncated")
  135. expect("outputPath" in result).toBe(false)
  136. }),
  137. )
  138. test("loads truncate effect in a fresh process", async () => {
  139. const out = await Process.run([process.execPath, "run", path.join(ROOT, "src", "tool", "truncate.ts")], {
  140. cwd: ROOT,
  141. })
  142. expect(out.code).toBe(0)
  143. }, 20000)
  144. })
  145. describe("cleanup", () => {
  146. const DAY_MS = 24 * 60 * 60 * 1000
  147. it.live("deletes files older than 7 days and preserves recent files", () =>
  148. Effect.gen(function* () {
  149. const svc = yield* Truncate.Service
  150. const fs = yield* FileSystem.FileSystem
  151. yield* fs.makeDirectory(Truncate.DIR, { recursive: true })
  152. const old = path.join(Truncate.DIR, Identifier.create("tool", "ascending", Date.now() - 10 * DAY_MS))
  153. const recent = path.join(Truncate.DIR, Identifier.create("tool", "ascending", Date.now() - 3 * DAY_MS))
  154. yield* writeFileStringScoped(old, "old content")
  155. yield* writeFileStringScoped(recent, "recent content")
  156. yield* svc.cleanup()
  157. expect(yield* fs.exists(old)).toBe(false)
  158. expect(yield* fs.exists(recent)).toBe(true)
  159. }),
  160. )
  161. })
  162. })