| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247 |
- import { describe, expect } from "bun:test"
- import path from "path"
- import { Cause, Effect, Exit, Fiber, Layer, Option } from "effect"
- import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
- import { LayerNode } from "@opencode-ai/core/effect/layer-node"
- import { FSUtil } from "@opencode-ai/core/fs-util"
- import { Global } from "@opencode-ai/core/global"
- import { Config } from "@opencode-ai/core/config"
- import { ConfigToolOutput } from "@opencode-ai/core/config/tool-output"
- import { SessionV2 } from "@opencode-ai/core/session"
- import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
- import { testEffect } from "./lib/effect"
- import { tmpdir } from "./fixture/tmpdir"
- const sessionID = SessionV2.ID.make("ses_tool_output_store")
- const withStore = <A, E, R>(
- body: (input: { root: string; store: ToolOutputStore.Interface; fs: FSUtil.Interface }) => Effect.Effect<A, E, R>,
- config?: Config.Info,
- ) =>
- Effect.acquireUseRelease(
- Effect.promise(() => tmpdir()),
- (tmp) => {
- const global = Global.layerWith({ data: tmp.path })
- const configured = config
- ? Layer.succeed(
- Config.Service,
- Config.Service.of({
- entries: () => Effect.succeed([new Config.Document({ type: "document", info: config })]),
- }),
- )
- : Layer.empty
- const store = AppNodeBuilder.build(LayerNode.group([ToolOutputStore.node, FSUtil.node]), [
- [Global.node, global],
- [Config.node, configured],
- ])
- return Effect.gen(function* () {
- return yield* body({ root: tmp.path, store: yield* ToolOutputStore.Service, fs: yield* FSUtil.Service })
- }).pipe(Effect.provide(store))
- },
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- )
- const it = testEffect(Layer.empty)
- describe("ToolOutputStore", () => {
- it.live("bounds the provider-facing text channel with one managed file", () =>
- withStore(({ store, fs }) =>
- Effect.gen(function* () {
- const first = "HEAD-" + "x".repeat(30_000)
- const second = "y".repeat(30_000) + "-TAIL"
- const result = yield* store.bound({
- sessionID,
- toolCallID: "call-aggregate",
- output: {
- structured: { kind: "report" },
- content: [
- { type: "text", text: first },
- { type: "text", text: second },
- ],
- },
- })
- expect(result.output.structured).toEqual({ kind: "report" })
- expect(result.outputPaths).toHaveLength(1)
- expect(yield* fs.readFileString(result.outputPaths[0])).toBe(first + second)
- if (result.output.content[0]?.type !== "text") throw new Error("expected text preview")
- expect(Buffer.byteLength(result.output.content[0].text)).toBeLessThanOrEqual(ToolOutputStore.MAX_BYTES)
- }),
- ),
- )
- it.live("uses bounded text for oversized structured-only output", () =>
- withStore(({ store, fs }) =>
- Effect.gen(function* () {
- const structured = { text: "x".repeat(ToolOutputStore.MAX_BYTES) }
- const result = yield* store.bound({ sessionID, toolCallID: "call-json", output: { structured, content: [] } })
- expect(result.output.structured).toEqual(structured)
- expect(result.outputPaths).toHaveLength(1)
- expect(JSON.parse(yield* fs.readFileString(result.outputPaths[0]))).toEqual(structured)
- expect(result.output.content).toHaveLength(1)
- }),
- ),
- )
- it.live("preserves native media and structured metadata without applying a settlement media limit", () =>
- withStore(({ store }) =>
- Effect.gen(function* () {
- const data = "a".repeat(6 * 1024 * 1024)
- const result = yield* store.bound({
- sessionID,
- toolCallID: "call-file",
- output: {
- structured: { caption: "pixel" },
- content: [{ type: "file", uri: `data:image/png;base64,${data}`, mime: "image/png", name: "pixel.png" }],
- },
- })
- expect(result.outputPaths).toEqual([])
- expect(result.output.structured).toEqual({ caption: "pixel" })
- expect(result.output.content).toHaveLength(1)
- expect(result.output.content[0]).toEqual({
- type: "file",
- uri: `data:image/png;base64,${data}`,
- mime: "image/png",
- name: "pixel.png",
- })
- }),
- ),
- )
- it.live("preserves structured metadata and native media when bounding text", () =>
- withStore(({ store, fs }) =>
- Effect.gen(function* () {
- const text = "x".repeat(ToolOutputStore.MAX_BYTES + 1)
- const media = {
- type: "file" as const,
- uri: "data:image/png;base64,aGVsbG8=",
- mime: "image/png",
- name: "pixel.png",
- }
- const result = yield* store.bound({
- sessionID,
- toolCallID: "call-text-and-media",
- output: { structured: { caption: "pixel" }, content: [{ type: "text", text }, media] },
- })
- expect(result.output.structured).toEqual({ caption: "pixel" })
- expect(result.output.content[1]).toEqual(media)
- expect(yield* fs.readFileString(result.outputPaths[0])).toBe(text)
- }),
- ),
- )
- it.live("does not double-count structured data duplicated in projected text", () =>
- withStore(({ store }) =>
- Effect.gen(function* () {
- const text = "x".repeat(30_000)
- const output = { structured: { output: text }, content: [{ type: "text" as const, text }] }
- expect(yield* store.bound({ sessionID, toolCallID: "call-duplicated", output })).toEqual({
- output,
- outputPaths: [],
- })
- }),
- ),
- )
- it.live("fails oversized settlement when complete retention cannot be written", () =>
- withStore(({ root, store, fs }) =>
- Effect.gen(function* () {
- yield* fs.writeFileString(path.join(root, "tool-output"), "not a directory")
- const exit = yield* store
- .bound({
- sessionID,
- toolCallID: "call-lossy",
- output: { structured: {}, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }] },
- })
- .pipe(Effect.exit)
- expect(Exit.isFailure(exit)).toBe(true)
- if (Exit.isFailure(exit))
- expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))?._tag).toBe("ToolOutputStore.StorageError")
- }),
- ),
- )
- it.live("does not encode ignored structured metadata when projected content exists", () =>
- withStore(({ store }) =>
- Effect.gen(function* () {
- const output = { structured: { value: 1n }, content: [{ type: "text" as const, text: "readable text" }] }
- expect(yield* store.bound({ sessionID, toolCallID: "call-unencodable", output })).toEqual({
- output,
- outputPaths: [],
- })
- }),
- ),
- )
- it.live("preserves interruption while retaining complete output", () =>
- Effect.gen(function* () {
- const root = yield* Effect.promise(() => tmpdir())
- const blockedFilesystem = Layer.effect(
- FSUtil.Service,
- Effect.gen(function* () {
- const fs = yield* FSUtil.Service
- return FSUtil.Service.of({
- ...fs,
- ensureDir: () => Effect.void,
- writeFileString: () => Effect.never,
- })
- }),
- ).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
- const store = AppNodeBuilder.build(ToolOutputStore.nodeWithoutConfig, [
- [Global.node, Global.layerWith({ data: root.path })],
- [FSUtil.node, blockedFilesystem],
- ])
- const exit = yield* Effect.gen(function* () {
- const service = yield* ToolOutputStore.Service
- const fiber = yield* service
- .bound({
- sessionID,
- toolCallID: "call-interrupted",
- output: { structured: {}, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }] },
- })
- .pipe(Effect.forkChild)
- yield* Fiber.interrupt(fiber)
- return yield* Fiber.await(fiber)
- }).pipe(Effect.provide(store))
- expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBe(true)
- yield* Effect.promise(() => root[Symbol.asyncDispose]())
- }),
- )
- it.live("honors configured limits", () =>
- withStore(
- ({ store }) =>
- Effect.gen(function* () {
- expect(yield* store.limits()).toEqual({ maxLines: 2, maxBytes: 1_000 })
- const result = yield* store.bound({
- sessionID,
- toolCallID: "call-config",
- output: { structured: {}, content: [{ type: "text", text: "one\ntwo\nthree" }] },
- })
- expect(result.outputPaths).toHaveLength(1)
- }),
- new Config.Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
- ),
- )
- it.live("cleans expired managed files and preserves unrelated files", () =>
- withStore(({ root, store, fs }) =>
- Effect.gen(function* () {
- const old = path.join(root, "tool-output", "tool_old")
- const recent = path.join(root, "tool-output", "tool_recent")
- const unrelated = path.join(root, "tool-output", "keep.txt")
- yield* fs.ensureDir(path.join(root, "tool-output"))
- yield* fs.writeFileString(old, "old")
- yield* fs.writeFileString(recent, "recent")
- yield* fs.writeFileString(unrelated, "keep")
- const expired = new Date(Date.now() - 8 * 24 * 60 * 60 * 1_000)
- yield* fs.utimes(old, expired, expired)
- yield* store.cleanup()
- expect(yield* fs.exists(old)).toBe(false)
- expect(yield* fs.exists(recent)).toBe(true)
- expect(yield* fs.exists(unrelated)).toBe(true)
- }),
- ),
- )
- })
|