tool-output.test.ts 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. import { describe, expect } from "bun:test"
  2. import path from "path"
  3. import { Effect, Layer, Stream } from "effect"
  4. import { Config } from "@opencode-ai/core/config"
  5. import { Document, Info } from "@opencode-ai/schema/config"
  6. import { ConfigToolOutput } from "@opencode-ai/schema/config/tool-output"
  7. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  8. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  9. import { ToolOutput } from "@opencode-ai/core/tool-output"
  10. import { FSUtil } from "@opencode-ai/util/fs-util"
  11. import { Global } from "@opencode-ai/util/global"
  12. import { Identifier } from "@opencode-ai/core/id/id"
  13. import { tmpdir } from "./fixture/tmpdir"
  14. import { it } from "./lib/effect"
  15. const withStore = <A, E, R>(
  16. body: (output: ToolOutput.Interface, fs: FSUtil.Interface, root: string) => Effect.Effect<A, E, R>,
  17. info = new Info(),
  18. ) =>
  19. Effect.acquireUseRelease(
  20. Effect.promise(() => tmpdir()),
  21. (tmp) => {
  22. const config = Layer.succeed(
  23. Config.Service,
  24. Config.Service.of({
  25. entries: () => Effect.succeed([new Document({ type: "document", info })]),
  26. changes: () => Stream.empty,
  27. }),
  28. )
  29. const layer = AppNodeBuilder.build(LayerNode.group([ToolOutput.node, FSUtil.node]), [
  30. [Config.node, config],
  31. [Global.node, Global.layerWith({ data: tmp.path })],
  32. ])
  33. return Effect.gen(function* () {
  34. return yield* body(yield* ToolOutput.Service, yield* FSUtil.Service, tmp.path)
  35. }).pipe(Effect.provide(layer))
  36. },
  37. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  38. )
  39. describe("ToolOutput", () => {
  40. it.live("writes oversized text and returns a bounded preview", () =>
  41. withStore(
  42. (service, fs) =>
  43. Effect.gen(function* () {
  44. const output = { items: [1, 2, 3] }
  45. const result = yield* service.truncate({ output, content: "one\ntwo\nthree" })
  46. expect(result.output).toBe(output)
  47. expect(result.metadata).toMatchObject({ truncated: true })
  48. const outputPath = result.metadata?.outputPath
  49. expect(typeof outputPath).toBe("string")
  50. if (typeof outputPath !== "string") return
  51. expect(yield* fs.readFileString(outputPath)).toBe("one\ntwo\nthree")
  52. expect(result.content).toEqual([
  53. { type: "text", text: "one\ntwo" },
  54. { type: "text", text: `... 1 line truncated; full content saved to ${outputPath} ...` },
  55. ])
  56. }),
  57. new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
  58. ),
  59. )
  60. it.live("reports bytes omitted by the byte limit", () =>
  61. withStore(
  62. (output) =>
  63. Effect.gen(function* () {
  64. const result = yield* output.truncate({ content: "one\ntwo" })
  65. expect(result.content).toEqual([
  66. { type: "text", text: "one" },
  67. {
  68. type: "text",
  69. text: expect.stringMatching(/^\.\.\. 4 bytes truncated; full content saved to .+ \.\.\.$/),
  70. },
  71. ])
  72. }),
  73. new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 100, max_bytes: 5 }) }),
  74. ),
  75. )
  76. it.live("preserves mixed content ordering", () =>
  77. withStore(
  78. (output) =>
  79. Effect.gen(function* () {
  80. const file = { type: "file" as const, uri: "file:///image.png", mime: "image/png" }
  81. const result = yield* output.truncate({
  82. content: [{ type: "text", text: "before" }, file, { type: "text", text: "after\nomitted" }],
  83. })
  84. expect(result.content).toEqual([
  85. { type: "text", text: "before" },
  86. file,
  87. { type: "text", text: "after" },
  88. { type: "text", text: expect.stringMatching(/^\.\.\. 1 line truncated; full content saved to /) },
  89. ])
  90. }),
  91. new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
  92. ),
  93. )
  94. it.live("skips results that report a truncation state", () =>
  95. withStore((output) =>
  96. Effect.gen(function* () {
  97. const truncated = { content: "one\ntwo", metadata: { truncated: true, source: "tool" } }
  98. const retained = { content: "one\ntwo", metadata: { truncated: false, source: "tool" } }
  99. expect(yield* output.truncate(truncated)).toBe(truncated)
  100. expect(yield* output.truncate(retained)).toBe(retained)
  101. }),
  102. ),
  103. )
  104. it.live("marks results that fit without changing their content", () =>
  105. withStore((output) =>
  106. Effect.gen(function* () {
  107. const content = [{ type: "text" as const, text: "small" }]
  108. expect(yield* output.truncate({ content })).toEqual({ content, metadata: { truncated: false } })
  109. }),
  110. ),
  111. )
  112. it.live("does not count a trailing newline as another line", () =>
  113. withStore(
  114. (output) =>
  115. Effect.gen(function* () {
  116. expect(yield* output.truncate({ content: "one\ntwo\n" })).toEqual({
  117. content: "one\ntwo\n",
  118. metadata: { truncated: false },
  119. })
  120. }),
  121. new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 1_000 }) }),
  122. ),
  123. )
  124. it.live("reports a trailing newline omitted by the byte limit", () =>
  125. withStore(
  126. (output) =>
  127. Effect.gen(function* () {
  128. const result = yield* output.truncate({ content: "one\n" })
  129. expect(result.content).toEqual([
  130. { type: "text", text: "one" },
  131. { type: "text", text: expect.stringMatching(/^\.\.\. 1 byte truncated; full content saved to /) },
  132. ])
  133. }),
  134. new Info({ tool_output: new ConfigToolOutput.Info({ max_lines: 2, max_bytes: 3 }) }),
  135. ),
  136. )
  137. it.live("removes expired managed files", () =>
  138. withStore((output, fs, root) =>
  139. Effect.gen(function* () {
  140. const directory = path.join(root, ToolOutput.DIRECTORY)
  141. const old = path.join(directory, Identifier.create("tool", "ascending", Date.now() - 8 * 24 * 60 * 60 * 1_000))
  142. const recent = path.join(directory, Identifier.ascending("tool"))
  143. yield* fs.ensureDir(directory)
  144. yield* fs.writeFileString(old, "old")
  145. yield* fs.writeFileString(recent, "recent")
  146. yield* output.cleanup()
  147. expect(yield* fs.exists(old)).toBe(false)
  148. expect(yield* fs.exists(recent)).toBe(true)
  149. }),
  150. ),
  151. )
  152. })