import { describe, expect } from "bun:test" import path from "path" import { Effect, Layer } from "effect" 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 otherSessionID = SessionV2.ID.make("ses_tool_output_store_other") const withStore = ( body: (input: { root: string; store: ToolOutputStore.Interface; fs: FSUtil.Interface }) => Effect.Effect, 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 = ToolOutputStore.layer.pipe( Layer.provide(FSUtil.defaultLayer), Layer.provide(global), Layer.provide(configured), ) return Effect.gen(function* () { return yield* body({ root: tmp.path, store: yield* ToolOutputStore.Service, fs: yield* FSUtil.Service }) }).pipe(Effect.provide(Layer.mergeAll(store, FSUtil.defaultLayer))) }, (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ) const it = testEffect(Layer.empty) describe("ToolOutputStore", () => { it.live("returns under-limit text unchanged without writing a resource", () => withStore(({ store }) => Effect.gen(function* () { expect(yield* store.truncate({ sessionID, toolCallID: "call-short", content: "line one\nline two" })).toEqual({ content: "line one\nline two", truncated: false, }) }), ), ) it.live("stores byte-truncated output and returns an opaque head-tail preview", () => withStore(({ store }) => Effect.gen(function* () { const content = "HEAD-" + "x".repeat(100) + "-TAIL" const result = yield* store.truncate({ sessionID, toolCallID: "call-bytes", content, maxBytes: 20 }) expect(result.truncated).toBe(true) if (!result.truncated) throw new Error("expected truncation") expect(result.content).toContain("HEAD-") expect(result.content).toContain("-TAIL") expect(result.content).toContain("output truncated") expect(result.resource.uri).toMatch(/^tool-output:\/\/[0-9A-Za-z]+$/) expect(result.resource.uri.slice("tool-output://".length)).not.toContain("/") expect(result.resource.uri).not.toContain("\\") expect(result.resource).toMatchObject({ mime: "text/plain", size: Buffer.byteLength(content) }) expect((yield* store.read({ sessionID, uri: result.resource.uri })).content).toBe(content) }), ), ) it.live("stores line-truncated output and keeps both ends in the preview", () => withStore(({ store }) => Effect.gen(function* () { const content = Array.from({ length: 10 }, (_, index) => `line-${index}`).join("\n") const result = yield* store.truncate({ sessionID, toolCallID: "call-lines", content, maxLines: 4 }) expect(result.truncated).toBe(true) if (!result.truncated) throw new Error("expected truncation") expect(result.content).toContain("line-0\nline-1") expect(result.content).toContain("line-8\nline-9") expect(result.content).not.toContain("line-4") }), ), ) it.live("keeps one-line previews bounded", () => withStore(({ store }) => Effect.gen(function* () { const result = yield* store.truncate({ sessionID, toolCallID: "call-one-line", content: "one\ntwo\nthree", maxLines: 1, }) expect(result.truncated).toBe(true) if (!result.truncated) throw new Error("expected truncation") const preview = result.content.split("\n\n... output truncated")[0] expect(preview).toBe("one") }), ), ) it.live("pages reads within the bounded managed-resource limit", () => withStore(({ root, store, fs }) => Effect.gen(function* () { const resource = yield* store.write({ sessionID, toolCallID: "call-page", content: "0123456789", name: "out.txt", }) const first = yield* store.read({ sessionID, uri: resource.uri, limit: 4 }) const second = yield* store.read({ sessionID, uri: resource.uri, offset: first.next, limit: 4 }) const last = yield* store.read({ sessionID, uri: resource.uri, offset: second.next, limit: 4 }) expect(first).toMatchObject({ content: "0123", offset: 0, truncated: true, next: 4 }) expect(second).toMatchObject({ content: "4567", offset: 4, truncated: true, next: 8 }) expect(last).toMatchObject({ content: "89", offset: 8, truncated: false }) expect(last.resource).toEqual({ uri: resource.uri, mime: "text/plain", name: "out.txt", size: 10 }) expect( JSON.parse( yield* fs.readFileString( path.join(root, "tool-output", "managed", `${resource.uri.slice("tool-output://".length)}.json`), ), ), ).toMatchObject({ sessionID, toolCallID: "call-page", }) const bounded = yield* store.read({ sessionID, uri: (yield* store.write({ sessionID, toolCallID: "call-bounded", content: "x".repeat(ToolOutputStore.MAX_READ_BYTES + 10), })).uri, limit: ToolOutputStore.MAX_READ_BYTES + 10, }) expect(Buffer.byteLength(bounded.content)).toBe(ToolOutputStore.MAX_READ_BYTES) expect(bounded).toMatchObject({ truncated: true, next: ToolOutputStore.MAX_READ_BYTES }) }), ), ) it.live("allows the owning session and denies cross-session reads", () => withStore(({ store }) => Effect.gen(function* () { const resource = yield* store.write({ sessionID, toolCallID: "call-owned", content: "owned" }) expect((yield* store.read({ sessionID, uri: resource.uri })).content).toBe("owned") expect(yield* Effect.flip(store.read({ sessionID: otherSessionID, uri: resource.uri }))).toBeInstanceOf( ToolOutputStore.AccessDeniedError, ) }), ), ) it.live("rejects resources whose payload size no longer matches metadata", () => withStore(({ root, store, fs }) => Effect.gen(function* () { const resource = yield* store.write({ sessionID, toolCallID: "call-modified", content: "original" }) const id = resource.uri.slice("tool-output://".length) yield* fs.writeFileString(path.join(root, "tool-output", "managed", `${id}.txt`), "changed payload") expect(yield* Effect.flip(store.read({ sessionID, uri: resource.uri }))).toBeInstanceOf( ToolOutputStore.ResourceNotFoundError, ) }), ), ) it.live("honors configured truncation limits", () => withStore( ({ store }) => Effect.gen(function* () { expect(yield* store.limits()).toEqual({ maxLines: 2, maxBytes: 1_000 }) expect( (yield* store.truncate({ sessionID, toolCallID: "call-config", content: "one\ntwo\nthree" })).truncated, ).toBe(true) }), new Config.Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }), ), ) it.live("cleans old managed resources while preserving recent and unrelated files", () => withStore(({ root, store, fs }) => Effect.gen(function* () { const old = yield* store.write({ sessionID, toolCallID: "call-old", content: "old" }) const recent = yield* store.write({ sessionID, toolCallID: "call-recent", content: "recent" }) const directory = path.join(root, "tool-output", "managed") const oldID = old.uri.slice("tool-output://".length) const recentID = recent.uri.slice("tool-output://".length) const oldMetadata = path.join(directory, `${oldID}.json`) const unrelated = path.join(root, "tool-output", "unrelated.txt") const unrelatedManaged = path.join(directory, "unrelated.txt") const record = JSON.parse(yield* fs.readFileString(oldMetadata)) yield* fs.writeFileString( oldMetadata, JSON.stringify({ ...record, created: Date.now() - 8 * 24 * 60 * 60 * 1_000 }), ) yield* fs.writeFileString(unrelated, "keep") yield* fs.writeFileString(unrelatedManaged, "keep") yield* store.cleanup() expect(yield* fs.exists(path.join(directory, `${oldID}.txt`))).toBe(false) expect(yield* fs.exists(oldMetadata)).toBe(false) expect(yield* fs.exists(path.join(directory, `${recentID}.txt`))).toBe(true) expect(yield* fs.exists(unrelated)).toBe(true) expect(yield* fs.exists(unrelatedManaged)).toBe(true) }), ), ) it.live("cleans stale generated orphan payloads and malformed pairs", () => withStore(({ root, store, fs }) => Effect.gen(function* () { const directory = path.join(root, "tool-output", "managed") yield* fs.ensureDir(directory) const orphanID = "00000000000000000000000000" const malformedID = "00000000000000000000000001" const orphan = path.join(directory, `${orphanID}.txt`) const malformedPayload = path.join(directory, `${malformedID}.txt`) const malformedMetadata = path.join(directory, `${malformedID}.json`) yield* fs.writeFileString(orphan, "orphan") yield* fs.writeFileString(malformedPayload, "malformed") yield* fs.writeFileString(malformedMetadata, "not json") const old = new Date(Date.now() - 8 * 24 * 60 * 60 * 1_000) yield* Effect.all([fs.utimes(orphan, old, old), fs.utimes(malformedPayload, old, old)]) yield* store.cleanup() expect(yield* fs.exists(orphan)).toBe(false) expect(yield* fs.exists(malformedPayload)).toBe(false) expect(yield* fs.exists(malformedMetadata)).toBe(false) }), ), ) it.live("cleans managed resources whose payload size no longer matches metadata", () => withStore(({ root, store, fs }) => Effect.gen(function* () { const resource = yield* store.write({ sessionID, toolCallID: "call-modified", content: "original" }) const directory = path.join(root, "tool-output", "managed") const id = resource.uri.slice("tool-output://".length) const payload = path.join(directory, `${id}.txt`) const metadata = path.join(directory, `${id}.json`) yield* fs.writeFileString(payload, "changed payload") yield* store.cleanup() expect(yield* fs.exists(payload)).toBe(false) expect(yield* fs.exists(metadata)).toBe(false) }), ), ) })