1
0

tool-output.test.ts 6.2 KB

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