tool-output-store.test.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. import { describe, expect } from "bun:test"
  2. import path from "path"
  3. import { Cause, Effect, Exit, Fiber, Layer, Option } 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("bounds the provider-facing text channel with one managed file", () =>
  43. withStore(({ store, fs }) =>
  44. Effect.gen(function* () {
  45. const first = "HEAD-" + "x".repeat(30_000)
  46. const second = "y".repeat(30_000) + "-TAIL"
  47. const result = yield* store.bound({
  48. sessionID,
  49. toolCallID: "call-aggregate",
  50. output: {
  51. structured: { kind: "report" },
  52. content: [
  53. { type: "text", text: first },
  54. { type: "text", text: second },
  55. ],
  56. },
  57. })
  58. expect(result.output.structured).toEqual({ kind: "report" })
  59. expect(result.outputPaths).toHaveLength(1)
  60. expect(yield* fs.readFileString(result.outputPaths[0])).toBe(first + second)
  61. if (result.output.content[0]?.type !== "text") throw new Error("expected text preview")
  62. expect(Buffer.byteLength(result.output.content[0].text)).toBeLessThanOrEqual(ToolOutputStore.MAX_BYTES)
  63. }),
  64. ),
  65. )
  66. it.live("uses bounded text for oversized structured-only output", () =>
  67. withStore(({ store, fs }) =>
  68. Effect.gen(function* () {
  69. const structured = { text: "x".repeat(ToolOutputStore.MAX_BYTES) }
  70. const result = yield* store.bound({ sessionID, toolCallID: "call-json", output: { structured, content: [] } })
  71. expect(result.output.structured).toEqual(structured)
  72. expect(result.outputPaths).toHaveLength(1)
  73. expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual(structured)
  74. expect(result.output.content).toHaveLength(1)
  75. }),
  76. ),
  77. )
  78. it.live("preserves native media and structured metadata without applying a settlement media limit", () =>
  79. withStore(({ store }) =>
  80. Effect.gen(function* () {
  81. const data = "a".repeat(6 * 1024 * 1024)
  82. const result = yield* store.bound({
  83. sessionID,
  84. toolCallID: "call-file",
  85. output: {
  86. structured: { caption: "pixel" },
  87. content: [{ type: "file", uri: `data:image/png;base64,${data}`, mime: "image/png", name: "pixel.png" }],
  88. },
  89. })
  90. expect(result.outputPaths).toEqual([])
  91. expect(result.output.structured).toEqual({ caption: "pixel" })
  92. expect(result.output.content).toHaveLength(1)
  93. expect(result.output.content[0]).toEqual({
  94. type: "file",
  95. uri: `data:image/png;base64,${data}`,
  96. mime: "image/png",
  97. name: "pixel.png",
  98. })
  99. }),
  100. ),
  101. )
  102. it.live("preserves structured metadata and native media when bounding text", () =>
  103. withStore(({ store, fs }) =>
  104. Effect.gen(function* () {
  105. const text = "x".repeat(ToolOutputStore.MAX_BYTES + 1)
  106. const media = {
  107. type: "file" as const,
  108. uri: "data:image/png;base64,aGVsbG8=",
  109. mime: "image/png",
  110. name: "pixel.png",
  111. }
  112. const result = yield* store.bound({
  113. sessionID,
  114. toolCallID: "call-text-and-media",
  115. output: { structured: { caption: "pixel" }, content: [{ type: "text", text }, media] },
  116. })
  117. expect(result.output.structured).toEqual({ caption: "pixel" })
  118. expect(result.output.content[1]).toEqual(media)
  119. expect(yield* fs.readFileString(result.outputPaths[0])).toBe(text)
  120. }),
  121. ),
  122. )
  123. it.live("does not double-count structured data duplicated in projected text", () =>
  124. withStore(({ store }) =>
  125. Effect.gen(function* () {
  126. const text = "x".repeat(30_000)
  127. const output = { structured: { output: text }, content: [{ type: "text" as const, text }] }
  128. expect(yield* store.bound({ sessionID, toolCallID: "call-duplicated", output })).toEqual({
  129. output,
  130. outputPaths: [],
  131. })
  132. }),
  133. ),
  134. )
  135. it.live("fails oversized settlement when complete retention cannot be written", () =>
  136. withStore(({ root, store, fs }) =>
  137. Effect.gen(function* () {
  138. yield* fs.writeFileString(path.join(root, "tool-output"), "not a directory")
  139. const exit = yield* store
  140. .bound({
  141. sessionID,
  142. toolCallID: "call-lossy",
  143. output: { structured: {}, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }] },
  144. })
  145. .pipe(Effect.exit)
  146. expect(Exit.isFailure(exit)).toBe(true)
  147. if (Exit.isFailure(exit))
  148. expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))?._tag).toBe("ToolOutputStore.StorageError")
  149. }),
  150. ),
  151. )
  152. it.live("does not encode ignored structured metadata when projected content exists", () =>
  153. withStore(({ store }) =>
  154. Effect.gen(function* () {
  155. const output = { structured: { value: 1n }, content: [{ type: "text" as const, text: "readable text" }] }
  156. expect(yield* store.bound({ sessionID, toolCallID: "call-unencodable", output })).toEqual({
  157. output,
  158. outputPaths: [],
  159. })
  160. }),
  161. ),
  162. )
  163. it.live("preserves interruption while retaining complete output", () =>
  164. Effect.gen(function* () {
  165. const root = yield* Effect.promise(() => tmpdir())
  166. const blockedFilesystem = Layer.effect(
  167. FSUtil.Service,
  168. Effect.gen(function* () {
  169. const fs = yield* FSUtil.Service
  170. return FSUtil.Service.of({
  171. ...fs,
  172. ensureDir: () => Effect.void,
  173. writeFileString: () => Effect.never,
  174. })
  175. }),
  176. ).pipe(Layer.provide(FSUtil.defaultLayer))
  177. const store = ToolOutputStore.layer.pipe(
  178. Layer.provide(blockedFilesystem),
  179. Layer.provide(Global.layerWith({ data: root.path })),
  180. )
  181. const exit = yield* Effect.gen(function* () {
  182. const service = yield* ToolOutputStore.Service
  183. const fiber = yield* service
  184. .bound({
  185. sessionID,
  186. toolCallID: "call-interrupted",
  187. output: { structured: {}, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }] },
  188. })
  189. .pipe(Effect.forkChild)
  190. yield* Fiber.interrupt(fiber)
  191. return yield* Fiber.await(fiber)
  192. }).pipe(Effect.provide(store))
  193. expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBe(true)
  194. yield* Effect.promise(() => root[Symbol.asyncDispose]())
  195. }),
  196. )
  197. it.live("honors configured limits", () =>
  198. withStore(
  199. ({ store }) =>
  200. Effect.gen(function* () {
  201. expect(yield* store.limits()).toEqual({ maxLines: 2, maxBytes: 1_000 })
  202. const result = yield* store.bound({
  203. sessionID,
  204. toolCallID: "call-config",
  205. output: { structured: {}, content: [{ type: "text", text: "one\ntwo\nthree" }] },
  206. })
  207. expect(result.outputPaths).toHaveLength(1)
  208. }),
  209. new Config.Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
  210. ),
  211. )
  212. it.live("cleans expired managed files and preserves unrelated files", () =>
  213. withStore(({ root, store, fs }) =>
  214. Effect.gen(function* () {
  215. const old = path.join(root, "tool-output", "tool_old")
  216. const recent = path.join(root, "tool-output", "tool_recent")
  217. const unrelated = path.join(root, "tool-output", "keep.txt")
  218. yield* fs.ensureDir(path.join(root, "tool-output"))
  219. yield* fs.writeFileString(old, "old")
  220. yield* fs.writeFileString(recent, "recent")
  221. yield* fs.writeFileString(unrelated, "keep")
  222. const expired = new Date(Date.now() - 8 * 24 * 60 * 60 * 1_000)
  223. yield* fs.utimes(old, expired, expired)
  224. yield* store.cleanup()
  225. expect(yield* fs.exists(old)).toBe(false)
  226. expect(yield* fs.exists(recent)).toBe(true)
  227. expect(yield* fs.exists(unrelated)).toBe(true)
  228. }),
  229. ),
  230. )
  231. })