| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161 |
- import { describe, expect } from "bun:test"
- import path from "path"
- import { Effect, Layer, Stream } from "effect"
- import { Config } from "@opencode-ai/core/config"
- import { Document, Info } from "@opencode-ai/schema/config"
- import { ConfigToolOutput } from "@opencode-ai/schema/config/tool-output"
- import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
- import { LayerNode } from "@opencode-ai/util/effect/layer-node"
- import { ToolOutput } from "@opencode-ai/core/tool-output"
- import { FSUtil } from "@opencode-ai/util/fs-util"
- import { Global } from "@opencode-ai/util/global"
- import { Identifier } from "@opencode-ai/core/id/id"
- import { tmpdir } from "./fixture/tmpdir"
- import { it } from "./lib/effect"
- const withStore = <A, E, R>(
- body: (output: ToolOutput.Interface, fs: FSUtil.Interface, root: string) => Effect.Effect<A, E, R>,
- info = new Info(),
- ) =>
- Effect.acquireUseRelease(
- Effect.promise(() => tmpdir()),
- (tmp) => {
- const config = Layer.succeed(
- Config.Service,
- Config.Service.of({
- entries: () => Effect.succeed([new Document({ type: "document", info })]),
- changes: () => Stream.empty,
- }),
- )
- const layer = AppNodeBuilder.build(LayerNode.group([ToolOutput.node, FSUtil.node]), [
- [Config.node, config],
- [Global.node, Global.layerWith({ data: tmp.path })],
- ])
- return Effect.gen(function* () {
- return yield* body(yield* ToolOutput.Service, yield* FSUtil.Service, tmp.path)
- }).pipe(Effect.provide(layer))
- },
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
- )
- describe("ToolOutput", () => {
- it.live("writes oversized text and returns a bounded preview", () =>
- withStore(
- (service, fs) =>
- Effect.gen(function* () {
- const output = { items: [1, 2, 3] }
- const result = yield* service.truncate({ output, content: "one\ntwo\nthree" })
- expect(result.output).toBe(output)
- expect(result.metadata).toMatchObject({ truncated: true })
- const outputPath = result.metadata?.outputPath
- expect(typeof outputPath).toBe("string")
- if (typeof outputPath !== "string") return
- expect(yield* fs.readFileString(outputPath)).toBe("one\ntwo\nthree")
- expect(result.content).toEqual([
- { type: "text", text: "one\ntwo" },
- { type: "text", text: `... 1 line truncated; full content saved to ${outputPath} ...` },
- ])
- }),
- new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
- ),
- )
- it.live("reports bytes omitted by the byte limit", () =>
- withStore(
- (output) =>
- Effect.gen(function* () {
- const result = yield* output.truncate({ content: "one\ntwo" })
- expect(result.content).toEqual([
- { type: "text", text: "one" },
- {
- type: "text",
- text: expect.stringMatching(/^\.\.\. 4 bytes truncated; full content saved to .+ \.\.\.$/),
- },
- ])
- }),
- new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 100, max_bytes: 5 }) }),
- ),
- )
- it.live("preserves mixed content ordering", () =>
- withStore(
- (output) =>
- Effect.gen(function* () {
- const file = { type: "file" as const, uri: "file:///image.png", mime: "image/png" }
- const result = yield* output.truncate({
- content: [{ type: "text", text: "before" }, file, { type: "text", text: "after\nomitted" }],
- })
- expect(result.content).toEqual([
- { type: "text", text: "before" },
- file,
- { type: "text", text: "after" },
- { type: "text", text: expect.stringMatching(/^\.\.\. 1 line truncated; full content saved to /) },
- ])
- }),
- new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
- ),
- )
- it.live("skips results that report a truncation state", () =>
- withStore((output) =>
- Effect.gen(function* () {
- const truncated = { content: "one\ntwo", metadata: { truncated: true, source: "tool" } }
- const retained = { content: "one\ntwo", metadata: { truncated: false, source: "tool" } }
- expect(yield* output.truncate(truncated)).toBe(truncated)
- expect(yield* output.truncate(retained)).toBe(retained)
- }),
- ),
- )
- it.live("marks results that fit without changing their content", () =>
- withStore((output) =>
- Effect.gen(function* () {
- const content = [{ type: "text" as const, text: "small" }]
- expect(yield* output.truncate({ content })).toEqual({ content, metadata: { truncated: false } })
- }),
- ),
- )
- it.live("does not count a trailing newline as another line", () =>
- withStore(
- (output) =>
- Effect.gen(function* () {
- expect(yield* output.truncate({ content: "one\ntwo\n" })).toEqual({
- content: "one\ntwo\n",
- metadata: { truncated: false },
- })
- }),
- new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
- ),
- )
- it.live("reports a trailing newline omitted by the byte limit", () =>
- withStore(
- (output) =>
- Effect.gen(function* () {
- const result = yield* output.truncate({ content: "one\n" })
- expect(result.content).toEqual([
- { type: "text", text: "one" },
- { type: "text", text: expect.stringMatching(/^\.\.\. 1 byte truncated; full content saved to /) },
- ])
- }),
- new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 3 }) }),
- ),
- )
- it.live("removes expired managed files", () =>
- withStore((output, fs, root) =>
- Effect.gen(function* () {
- const directory = path.join(root, ToolOutput.DIRECTORY)
- const old = path.join(directory, Identifier.create("tool", "ascending", Date.now() - 8 * 24 * 60 * 60 * 1_000))
- const recent = path.join(directory, Identifier.ascending("tool"))
- yield* fs.ensureDir(directory)
- yield* fs.writeFileString(old, "old")
- yield* fs.writeFileString(recent, "recent")
- yield* output.cleanup()
- expect(yield* fs.exists(old)).toBe(false)
- expect(yield* fs.exists(recent)).toBe(true)
- }),
- ),
- )
- })
|