tool-output-store.test.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156
  1. import { describe, expect } from "bun:test"
  2. import path from "path"
  3. import { Effect, Layer } from "effect"
  4. import { FSUtil } from "@opencode-ai/core/fs-util"
  5. import { Global } from "@opencode-ai/core/global"
  6. import { Config } from "@opencode-ai/core/config"
  7. import { ConfigToolOutput } from "@opencode-ai/core/config/tool-output"
  8. import { SessionV2 } from "@opencode-ai/core/session"
  9. import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
  10. import { testEffect } from "./lib/effect"
  11. import { tmpdir } from "./fixture/tmpdir"
  12. const sessionID = SessionV2.ID.make("ses_tool_output_store")
  13. const withStore = <A, E, R>(
  14. body: (input: { root: string; store: ToolOutputStore.Interface; fs: FSUtil.Interface }) => Effect.Effect<A, E, R>,
  15. config?: Config.Info,
  16. ) =>
  17. Effect.acquireUseRelease(
  18. Effect.promise(() => tmpdir()),
  19. (tmp) => {
  20. const global = Global.layerWith({ data: tmp.path })
  21. const configured = config
  22. ? Layer.succeed(
  23. Config.Service,
  24. Config.Service.of({
  25. entries: () => Effect.succeed([new Config.Document({ type: "document", info: config })]),
  26. }),
  27. )
  28. : Layer.empty
  29. const store = ToolOutputStore.layer.pipe(
  30. Layer.provide(FSUtil.defaultLayer),
  31. Layer.provide(global),
  32. Layer.provide(configured),
  33. )
  34. return Effect.gen(function* () {
  35. return yield* body({ root: tmp.path, store: yield* ToolOutputStore.Service, fs: yield* FSUtil.Service })
  36. }).pipe(Effect.provide(Layer.mergeAll(store, FSUtil.defaultLayer)))
  37. },
  38. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  39. )
  40. const it = testEffect(Layer.empty)
  41. describe("ToolOutputStore", () => {
  42. it.live("returns under-limit text unchanged without writing a file", () =>
  43. withStore(({ store }) =>
  44. Effect.gen(function* () {
  45. expect(yield* store.truncate({ sessionID, toolCallID: "call-short", content: "one\ntwo" })).toEqual({
  46. content: "one\ntwo",
  47. truncated: false,
  48. })
  49. }),
  50. ),
  51. )
  52. it.live("stores full output at an absolute managed path", () =>
  53. withStore(({ root, store, fs }) =>
  54. Effect.gen(function* () {
  55. const content = "HEAD-" + "x".repeat(500) + "-TAIL"
  56. const result = yield* store.truncate({ sessionID, toolCallID: "call-large", content, maxBytes: 300 })
  57. expect(result.truncated).toBe(true)
  58. if (!result.truncated) throw new Error("expected truncation")
  59. expect(path.isAbsolute(result.outputPath)).toBe(true)
  60. expect(result.outputPath).toStartWith(path.join(root, "tool-output", "tool_"))
  61. expect(result.content).toContain(result.outputPath)
  62. expect(result.content).toContain("HEAD-")
  63. expect(result.content).toContain("-TAIL")
  64. expect(yield* fs.readFileString(result.outputPath)).toBe(content)
  65. }),
  66. ),
  67. )
  68. it.live("bounds aggregate text blocks with one managed file", () =>
  69. withStore(({ store, fs }) =>
  70. Effect.gen(function* () {
  71. const first = "HEAD-" + "x".repeat(30_000)
  72. const second = "y".repeat(30_000) + "-TAIL"
  73. const result = yield* store.bound({
  74. sessionID,
  75. toolCallID: "call-aggregate",
  76. output: {
  77. structured: { kind: "report" },
  78. content: [
  79. { type: "text", text: first },
  80. { type: "text", text: second },
  81. ],
  82. },
  83. })
  84. expect(result.output.structured).toEqual({ kind: "report" })
  85. expect(result.outputPaths).toHaveLength(1)
  86. expect(yield* fs.readFileString(result.outputPaths[0]!)).toBe(`${first}\n\n${second}`)
  87. if (result.output.content[0]?.type !== "text") throw new Error("expected text preview")
  88. expect(Buffer.byteLength(result.output.content[0].text)).toBeLessThanOrEqual(ToolOutputStore.MAX_BYTES)
  89. }),
  90. ),
  91. )
  92. it.live("uses bounded text for oversized structured-only output", () =>
  93. withStore(({ store, fs }) =>
  94. Effect.gen(function* () {
  95. const structured = { text: "x".repeat(ToolOutputStore.MAX_BYTES) }
  96. const result = yield* store.bound({ sessionID, toolCallID: "call-json", output: { structured, content: [] } })
  97. expect(result.output.structured).toBe(structured)
  98. expect(result.outputPaths).toHaveLength(1)
  99. expect(yield* fs.readFileString(result.outputPaths[0]!)).toBe(JSON.stringify(structured))
  100. }),
  101. ),
  102. )
  103. it.live("degrades to lossy bounded output when writing fails", () =>
  104. withStore(({ root, store, fs }) =>
  105. Effect.gen(function* () {
  106. yield* fs.writeFileString(path.join(root, "tool-output"), "not a directory")
  107. const result = yield* store.bound({
  108. sessionID,
  109. toolCallID: "call-lossy",
  110. output: { structured: {}, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }] },
  111. })
  112. expect(result.outputPaths).toEqual([])
  113. if (result.output.content[0]?.type !== "text") throw new Error("expected text preview")
  114. expect(result.output.content[0].text).toContain("could not be retained")
  115. }),
  116. ),
  117. )
  118. it.live("honors configured limits", () =>
  119. withStore(
  120. ({ store }) =>
  121. Effect.gen(function* () {
  122. expect(yield* store.limits()).toEqual({ maxLines: 2, maxBytes: 1_000 })
  123. expect(
  124. (yield* store.truncate({ sessionID, toolCallID: "call-config", content: "one\ntwo\nthree" })).truncated,
  125. ).toBe(true)
  126. }),
  127. new Config.Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
  128. ),
  129. )
  130. it.live("cleans expired managed files and preserves unrelated files", () =>
  131. withStore(({ root, store, fs }) =>
  132. Effect.gen(function* () {
  133. const old = yield* store.write({ sessionID, toolCallID: "old", content: "old" })
  134. const recent = yield* store.write({ sessionID, toolCallID: "recent", content: "recent" })
  135. const unrelated = path.join(root, "tool-output", "keep.txt")
  136. yield* fs.writeFileString(unrelated, "keep")
  137. const expired = new Date(Date.now() - 8 * 24 * 60 * 60 * 1_000)
  138. yield* fs.utimes(old, expired, expired)
  139. yield* store.cleanup()
  140. expect(yield* fs.exists(old)).toBe(false)
  141. expect(yield* fs.exists(recent)).toBe(true)
  142. expect(yield* fs.exists(unrelated)).toBe(true)
  143. }),
  144. ),
  145. )
  146. })