tool-output-store.test.ts 9.5 KB

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