truncation.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. import { describe, test, expect } from "bun:test"
  2. import { ConfigV1 } from "@kirincode-ai/core/v1/config/config"
  3. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  4. import { filesystem } from "@kirincode-ai/core/effect/app-node-platform"
  5. import { FSUtil } from "@kirincode-ai/core/fs-util"
  6. import { Effect, FileSystem } from "effect"
  7. import { Truncate } from "@/tool/truncate"
  8. import { Config } from "@/config/config"
  9. import { Identifier } from "../../src/id/id"
  10. import { Process } from "@/util/process"
  11. import path from "path"
  12. import { testEffect } from "../lib/effect"
  13. import { writeFileStringScoped } from "../lib/filesystem"
  14. import { TestConfig } from "../fixture/config"
  15. const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
  16. const ROOT = path.resolve(import.meta.dir, "..", "..")
  17. const it = testEffect(LayerNode.compile(LayerNode.group([Truncate.node, FSUtil.node, filesystem])))
  18. const configuredLayer = (cfg: ConfigV1.Info) =>
  19. LayerNode.compile(LayerNode.group([Truncate.node, FSUtil.node, filesystem, Config.node]), [
  20. [Config.node, TestConfig.layer({ get: () => Effect.succeed(cfg) })],
  21. ])
  22. const configuredIt = (cfg: ConfigV1.Info) => testEffect(configuredLayer(cfg))
  23. describe("Truncate", () => {
  24. describe("output", () => {
  25. it.live("truncates large json file by bytes", () =>
  26. Effect.gen(function* () {
  27. const svc = yield* Truncate.Service
  28. const fsys = yield* FSUtil.Service
  29. const content = yield* fsys.readFileString(path.join(FIXTURES_DIR, "models-api.json"))
  30. const result = yield* svc.output(content)
  31. expect(result.truncated).toBe(true)
  32. expect(result.content).toContain("truncated...")
  33. if (result.truncated) expect(result.outputPath).toBeDefined()
  34. }),
  35. )
  36. it.live("returns content unchanged when under limits", () =>
  37. Effect.gen(function* () {
  38. const svc = yield* Truncate.Service
  39. const content = "line1\nline2\nline3"
  40. const result = yield* svc.output(content)
  41. expect(result.truncated).toBe(false)
  42. expect(result.content).toBe(content)
  43. }),
  44. )
  45. it.live("truncates by line count", () =>
  46. Effect.gen(function* () {
  47. const svc = yield* Truncate.Service
  48. const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
  49. const result = yield* svc.output(lines, { maxLines: 10 })
  50. expect(result.truncated).toBe(true)
  51. expect(result.content).toContain("...90 lines truncated...")
  52. }),
  53. )
  54. it.live("truncates by byte count", () =>
  55. Effect.gen(function* () {
  56. const svc = yield* Truncate.Service
  57. const content = "a".repeat(1000)
  58. const result = yield* svc.output(content, { maxBytes: 100 })
  59. expect(result.truncated).toBe(true)
  60. expect(result.content).toContain("truncated...")
  61. }),
  62. )
  63. it.live("truncates from head by default", () =>
  64. Effect.gen(function* () {
  65. const svc = yield* Truncate.Service
  66. const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
  67. const result = yield* svc.output(lines, { maxLines: 3 })
  68. expect(result.truncated).toBe(true)
  69. expect(result.content).toContain("line0")
  70. expect(result.content).toContain("line1")
  71. expect(result.content).toContain("line2")
  72. expect(result.content).not.toContain("line9")
  73. }),
  74. )
  75. it.live("truncates from tail when direction is tail", () =>
  76. Effect.gen(function* () {
  77. const svc = yield* Truncate.Service
  78. const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
  79. const result = yield* svc.output(lines, { maxLines: 3, direction: "tail" })
  80. expect(result.truncated).toBe(true)
  81. expect(result.content).toContain("line7")
  82. expect(result.content).toContain("line8")
  83. expect(result.content).toContain("line9")
  84. expect(result.content).not.toContain("line0")
  85. }),
  86. )
  87. test("uses default MAX_LINES and MAX_BYTES", () => {
  88. expect(Truncate.MAX_LINES).toBe(2000)
  89. expect(Truncate.MAX_BYTES).toBe(50 * 1024)
  90. })
  91. it.live("limits() falls back to MAX_LINES/MAX_BYTES when Config is not provided", () =>
  92. Effect.gen(function* () {
  93. const svc = yield* Truncate.Service
  94. const resolved = yield* svc.limits()
  95. expect(resolved.maxLines).toBe(Truncate.MAX_LINES)
  96. expect(resolved.maxBytes).toBe(Truncate.MAX_BYTES)
  97. }),
  98. )
  99. describe("with tool_output config", () => {
  100. const limitsIt = configuredIt({ tool_output: { max_lines: 123, max_bytes: 456 } })
  101. limitsIt.live("limits() reflects config overrides", () =>
  102. Effect.gen(function* () {
  103. const resolved = yield* (yield* Truncate.Service).limits()
  104. expect(resolved.maxLines).toBe(123)
  105. expect(resolved.maxBytes).toBe(456)
  106. }),
  107. )
  108. // Huge byte budget isolates line truncation. 100 lines against max_lines: 10
  109. // proves the configured line limit is what `output()` enforces.
  110. const lineIt = configuredIt({ tool_output: { max_lines: 10, max_bytes: 1024 * 1024 } })
  111. lineIt.live("output() truncates to configured max_lines", () =>
  112. Effect.gen(function* () {
  113. const content = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
  114. const result = yield* (yield* Truncate.Service).output(content)
  115. expect(result.truncated).toBe(true)
  116. expect(result.content).toContain("...90 lines truncated...")
  117. }),
  118. )
  119. // Huge line budget isolates byte truncation.
  120. const byteIt = configuredIt({ tool_output: { max_lines: 1_000_000, max_bytes: 100 } })
  121. byteIt.live("output() truncates to configured max_bytes", () =>
  122. Effect.gen(function* () {
  123. const content = "a".repeat(1000)
  124. const result = yield* (yield* Truncate.Service).output(content)
  125. expect(result.truncated).toBe(true)
  126. expect(result.content).toContain("bytes truncated...")
  127. }),
  128. )
  129. const overrideIt = configuredIt({ tool_output: { max_lines: 10, max_bytes: 100 } })
  130. overrideIt.live("per-call options still override config", () =>
  131. Effect.gen(function* () {
  132. const content = Array.from({ length: 50 }, (_, i) => `line${i}`).join("\n")
  133. const result = yield* (yield* Truncate.Service).output(content, {
  134. maxLines: 1000,
  135. maxBytes: 1024 * 1024,
  136. })
  137. expect(result.truncated).toBe(false)
  138. }),
  139. )
  140. })
  141. it.live("large single-line file truncates with byte message", () =>
  142. Effect.gen(function* () {
  143. const svc = yield* Truncate.Service
  144. const fsys = yield* FSUtil.Service
  145. const content = yield* fsys.readFileString(path.join(FIXTURES_DIR, "models-api.json"))
  146. const result = yield* svc.output(content)
  147. expect(result.truncated).toBe(true)
  148. expect(result.content).toContain("bytes truncated...")
  149. expect(Buffer.byteLength(content, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES)
  150. }),
  151. )
  152. it.live("writes full output to file when truncated", () =>
  153. Effect.gen(function* () {
  154. const svc = yield* Truncate.Service
  155. const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
  156. const result = yield* svc.output(lines, { maxLines: 10 })
  157. expect(result.truncated).toBe(true)
  158. expect(result.content).toContain("The tool call succeeded but the output was truncated")
  159. expect(result.content).toContain("Grep")
  160. if (!result.truncated) throw new Error("expected truncated")
  161. expect(result.outputPath).toBeDefined()
  162. expect(result.outputPath).toContain("tool_")
  163. const fsys = yield* FSUtil.Service
  164. const written = yield* fsys.readFileString(result.outputPath!)
  165. expect(written).toBe(lines)
  166. }),
  167. )
  168. it.live("suggests Task tool when agent has task permission", () =>
  169. Effect.gen(function* () {
  170. const svc = yield* Truncate.Service
  171. const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
  172. const agent = { permission: [{ permission: "task", pattern: "*", action: "allow" as const }] }
  173. const result = yield* svc.output(lines, { maxLines: 10 }, agent as any)
  174. expect(result.truncated).toBe(true)
  175. expect(result.content).toContain("Grep")
  176. expect(result.content).toContain("Task tool")
  177. }),
  178. )
  179. it.live("omits Task tool hint when agent lacks task permission", () =>
  180. Effect.gen(function* () {
  181. const svc = yield* Truncate.Service
  182. const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
  183. const agent = { permission: [{ permission: "task", pattern: "*", action: "deny" as const }] }
  184. const result = yield* svc.output(lines, { maxLines: 10 }, agent as any)
  185. expect(result.truncated).toBe(true)
  186. expect(result.content).toContain("Grep")
  187. expect(result.content).not.toContain("Task tool")
  188. }),
  189. )
  190. it.live("does not write file when not truncated", () =>
  191. Effect.gen(function* () {
  192. const svc = yield* Truncate.Service
  193. const content = "short content"
  194. const result = yield* svc.output(content)
  195. expect(result.truncated).toBe(false)
  196. if (result.truncated) throw new Error("expected not truncated")
  197. expect("outputPath" in result).toBe(false)
  198. }),
  199. )
  200. test("loads truncate effect in a fresh process", async () => {
  201. const out = await Process.run([process.execPath, "run", path.join(ROOT, "src", "tool", "truncate.ts")], {
  202. cwd: ROOT,
  203. })
  204. expect(out.code).toBe(0)
  205. }, 20000)
  206. })
  207. describe("cleanup", () => {
  208. const DAY_MS = 24 * 60 * 60 * 1000
  209. it.live("deletes files older than 7 days and preserves recent files", () =>
  210. Effect.gen(function* () {
  211. const svc = yield* Truncate.Service
  212. const fs = yield* FileSystem.FileSystem
  213. yield* fs.makeDirectory(Truncate.DIR, { recursive: true })
  214. const old = path.join(Truncate.DIR, Identifier.create("tool", "ascending", Date.now() - 10 * DAY_MS))
  215. const recent = path.join(Truncate.DIR, Identifier.create("tool", "ascending", Date.now() - 3 * DAY_MS))
  216. yield* writeFileStringScoped(old, "old content")
  217. yield* writeFileStringScoped(recent, "recent content")
  218. yield* svc.cleanup()
  219. expect(yield* fs.exists(old)).toBe(false)
  220. expect(yield* fs.exists(recent)).toBe(true)
  221. }),
  222. )
  223. })
  224. })