tool-output-store.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  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 otherSessionID = SessionV2.ID.make("ses_tool_output_store_other")
  14. const withStore = <A, E, R>(
  15. body: (input: { root: string; store: ToolOutputStore.Interface; fs: FSUtil.Interface }) => Effect.Effect<A, E, R>,
  16. config?: Config.Info,
  17. ) =>
  18. Effect.acquireUseRelease(
  19. Effect.promise(() => tmpdir()),
  20. (tmp) => {
  21. const global = Global.layerWith({ data: tmp.path })
  22. const configured = config
  23. ? Layer.succeed(
  24. Config.Service,
  25. Config.Service.of({
  26. entries: () => Effect.succeed([new Config.Document({ type: "document", info: config })]),
  27. }),
  28. )
  29. : Layer.empty
  30. const store = ToolOutputStore.layer.pipe(
  31. Layer.provide(FSUtil.defaultLayer),
  32. Layer.provide(global),
  33. Layer.provide(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(Layer.mergeAll(store, FSUtil.defaultLayer)))
  38. },
  39. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  40. )
  41. const it = testEffect(Layer.empty)
  42. describe("ToolOutputStore", () => {
  43. it.live("returns under-limit text unchanged without writing a resource", () =>
  44. withStore(({ store }) =>
  45. Effect.gen(function* () {
  46. expect(yield* store.truncate({ sessionID, toolCallID: "call-short", content: "line one\nline two" })).toEqual({
  47. content: "line one\nline two",
  48. truncated: false,
  49. })
  50. }),
  51. ),
  52. )
  53. it.live("stores byte-truncated output and returns an opaque head-tail preview", () =>
  54. withStore(({ store }) =>
  55. Effect.gen(function* () {
  56. const content = "HEAD-" + "x".repeat(100) + "-TAIL"
  57. const result = yield* store.truncate({ sessionID, toolCallID: "call-bytes", content, maxBytes: 20 })
  58. expect(result.truncated).toBe(true)
  59. if (!result.truncated) throw new Error("expected truncation")
  60. expect(result.content).toContain("HEAD-")
  61. expect(result.content).toContain("-TAIL")
  62. expect(result.content).toContain("output truncated")
  63. expect(result.resource.uri).toMatch(/^tool-output:\/\/[0-9A-Za-z]+$/)
  64. expect(result.resource.uri.slice("tool-output://".length)).not.toContain("/")
  65. expect(result.resource.uri).not.toContain("\\")
  66. expect(result.resource).toMatchObject({ mime: "text/plain", size: Buffer.byteLength(content) })
  67. expect((yield* store.read({ sessionID, uri: result.resource.uri })).content).toBe(content)
  68. }),
  69. ),
  70. )
  71. it.live("stores line-truncated output and keeps both ends in the preview", () =>
  72. withStore(({ store }) =>
  73. Effect.gen(function* () {
  74. const content = Array.from({ length: 10 }, (_, index) => `line-${index}`).join("\n")
  75. const result = yield* store.truncate({ sessionID, toolCallID: "call-lines", content, maxLines: 4 })
  76. expect(result.truncated).toBe(true)
  77. if (!result.truncated) throw new Error("expected truncation")
  78. expect(result.content).toContain("line-0\nline-1")
  79. expect(result.content).toContain("line-8\nline-9")
  80. expect(result.content).not.toContain("line-4")
  81. }),
  82. ),
  83. )
  84. it.live("keeps one-line previews bounded", () =>
  85. withStore(({ store }) =>
  86. Effect.gen(function* () {
  87. const result = yield* store.truncate({
  88. sessionID,
  89. toolCallID: "call-one-line",
  90. content: "one\ntwo\nthree",
  91. maxLines: 1,
  92. })
  93. expect(result.truncated).toBe(true)
  94. if (!result.truncated) throw new Error("expected truncation")
  95. const preview = result.content.split("\n\n... output truncated")[0]
  96. expect(preview).toBe("one")
  97. }),
  98. ),
  99. )
  100. it.live("pages reads within the bounded managed-resource limit", () =>
  101. withStore(({ root, store, fs }) =>
  102. Effect.gen(function* () {
  103. const resource = yield* store.write({
  104. sessionID,
  105. toolCallID: "call-page",
  106. content: "0123456789",
  107. name: "out.txt",
  108. })
  109. const first = yield* store.read({ sessionID, uri: resource.uri, limit: 4 })
  110. const second = yield* store.read({ sessionID, uri: resource.uri, offset: first.next, limit: 4 })
  111. const last = yield* store.read({ sessionID, uri: resource.uri, offset: second.next, limit: 4 })
  112. expect(first).toMatchObject({ content: "0123", offset: 0, truncated: true, next: 4 })
  113. expect(second).toMatchObject({ content: "4567", offset: 4, truncated: true, next: 8 })
  114. expect(last).toMatchObject({ content: "89", offset: 8, truncated: false })
  115. expect(last.resource).toEqual({ uri: resource.uri, mime: "text/plain", name: "out.txt", size: 10 })
  116. expect(
  117. JSON.parse(
  118. yield* fs.readFileString(
  119. path.join(root, "tool-output", "managed", `${resource.uri.slice("tool-output://".length)}.json`),
  120. ),
  121. ),
  122. ).toMatchObject({
  123. sessionID,
  124. toolCallID: "call-page",
  125. })
  126. const bounded = yield* store.read({
  127. sessionID,
  128. uri: (yield* store.write({
  129. sessionID,
  130. toolCallID: "call-bounded",
  131. content: "x".repeat(ToolOutputStore.MAX_READ_BYTES + 10),
  132. })).uri,
  133. limit: ToolOutputStore.MAX_READ_BYTES + 10,
  134. })
  135. expect(Buffer.byteLength(bounded.content)).toBe(ToolOutputStore.MAX_READ_BYTES)
  136. expect(bounded).toMatchObject({ truncated: true, next: ToolOutputStore.MAX_READ_BYTES })
  137. }),
  138. ),
  139. )
  140. it.live("allows the owning session and denies cross-session reads", () =>
  141. withStore(({ store }) =>
  142. Effect.gen(function* () {
  143. const resource = yield* store.write({ sessionID, toolCallID: "call-owned", content: "owned" })
  144. expect((yield* store.read({ sessionID, uri: resource.uri })).content).toBe("owned")
  145. expect(yield* Effect.flip(store.read({ sessionID: otherSessionID, uri: resource.uri }))).toBeInstanceOf(
  146. ToolOutputStore.AccessDeniedError,
  147. )
  148. }),
  149. ),
  150. )
  151. it.live("rejects resources whose payload size no longer matches metadata", () =>
  152. withStore(({ root, store, fs }) =>
  153. Effect.gen(function* () {
  154. const resource = yield* store.write({ sessionID, toolCallID: "call-modified", content: "original" })
  155. const id = resource.uri.slice("tool-output://".length)
  156. yield* fs.writeFileString(path.join(root, "tool-output", "managed", `${id}.txt`), "changed payload")
  157. expect(yield* Effect.flip(store.read({ sessionID, uri: resource.uri }))).toBeInstanceOf(
  158. ToolOutputStore.ResourceNotFoundError,
  159. )
  160. }),
  161. ),
  162. )
  163. it.live("honors configured truncation limits", () =>
  164. withStore(
  165. ({ store }) =>
  166. Effect.gen(function* () {
  167. expect(yield* store.limits()).toEqual({ maxLines: 2, maxBytes: 1_000 })
  168. expect(
  169. (yield* store.truncate({ sessionID, toolCallID: "call-config", content: "one\ntwo\nthree" })).truncated,
  170. ).toBe(true)
  171. }),
  172. new Config.Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
  173. ),
  174. )
  175. it.live("cleans old managed resources while preserving recent and unrelated files", () =>
  176. withStore(({ root, store, fs }) =>
  177. Effect.gen(function* () {
  178. const old = yield* store.write({ sessionID, toolCallID: "call-old", content: "old" })
  179. const recent = yield* store.write({ sessionID, toolCallID: "call-recent", content: "recent" })
  180. const directory = path.join(root, "tool-output", "managed")
  181. const oldID = old.uri.slice("tool-output://".length)
  182. const recentID = recent.uri.slice("tool-output://".length)
  183. const oldMetadata = path.join(directory, `${oldID}.json`)
  184. const unrelated = path.join(root, "tool-output", "unrelated.txt")
  185. const unrelatedManaged = path.join(directory, "unrelated.txt")
  186. const record = JSON.parse(yield* fs.readFileString(oldMetadata))
  187. yield* fs.writeFileString(
  188. oldMetadata,
  189. JSON.stringify({ ...record, created: Date.now() - 8 * 24 * 60 * 60 * 1_000 }),
  190. )
  191. yield* fs.writeFileString(unrelated, "keep")
  192. yield* fs.writeFileString(unrelatedManaged, "keep")
  193. yield* store.cleanup()
  194. expect(yield* fs.exists(path.join(directory, `${oldID}.txt`))).toBe(false)
  195. expect(yield* fs.exists(oldMetadata)).toBe(false)
  196. expect(yield* fs.exists(path.join(directory, `${recentID}.txt`))).toBe(true)
  197. expect(yield* fs.exists(unrelated)).toBe(true)
  198. expect(yield* fs.exists(unrelatedManaged)).toBe(true)
  199. }),
  200. ),
  201. )
  202. it.live("cleans stale generated orphan payloads and malformed pairs", () =>
  203. withStore(({ root, store, fs }) =>
  204. Effect.gen(function* () {
  205. const directory = path.join(root, "tool-output", "managed")
  206. yield* fs.ensureDir(directory)
  207. const orphanID = "00000000000000000000000000"
  208. const malformedID = "00000000000000000000000001"
  209. const orphan = path.join(directory, `${orphanID}.txt`)
  210. const malformedPayload = path.join(directory, `${malformedID}.txt`)
  211. const malformedMetadata = path.join(directory, `${malformedID}.json`)
  212. yield* fs.writeFileString(orphan, "orphan")
  213. yield* fs.writeFileString(malformedPayload, "malformed")
  214. yield* fs.writeFileString(malformedMetadata, "not json")
  215. const old = new Date(Date.now() - 8 * 24 * 60 * 60 * 1_000)
  216. yield* Effect.all([fs.utimes(orphan, old, old), fs.utimes(malformedPayload, old, old)])
  217. yield* store.cleanup()
  218. expect(yield* fs.exists(orphan)).toBe(false)
  219. expect(yield* fs.exists(malformedPayload)).toBe(false)
  220. expect(yield* fs.exists(malformedMetadata)).toBe(false)
  221. }),
  222. ),
  223. )
  224. it.live("cleans managed resources whose payload size no longer matches metadata", () =>
  225. withStore(({ root, store, fs }) =>
  226. Effect.gen(function* () {
  227. const resource = yield* store.write({ sessionID, toolCallID: "call-modified", content: "original" })
  228. const directory = path.join(root, "tool-output", "managed")
  229. const id = resource.uri.slice("tool-output://".length)
  230. const payload = path.join(directory, `${id}.txt`)
  231. const metadata = path.join(directory, `${id}.json`)
  232. yield* fs.writeFileString(payload, "changed payload")
  233. yield* store.cleanup()
  234. expect(yield* fs.exists(payload)).toBe(false)
  235. expect(yield* fs.exists(metadata)).toBe(false)
  236. }),
  237. ),
  238. )
  239. })