tool-execute.test.ts 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  1. import { expect, test } from "bun:test"
  2. import { ExecuteTool } from "@opencode-ai/core/tool/execute"
  3. import { Tool } from "@opencode-ai/core/tool/tool"
  4. import { Agent } from "@opencode-ai/schema/agent"
  5. import { Session } from "@opencode-ai/schema/session"
  6. import { SessionMessage } from "@opencode-ai/schema/session-message"
  7. import { Deferred, Effect, Fiber, Schema } from "effect"
  8. const context = {
  9. sessionID: Session.ID.make("ses_execute"),
  10. agent: Agent.ID.make("build"),
  11. messageID: SessionMessage.ID.make("msg_execute"),
  12. callID: "call_execute",
  13. progress: () => Effect.void,
  14. }
  15. test("execute describes invariant Code Mode behavior", () => {
  16. expect(ExecuteTool.create(new Map()).description).toBe(
  17. [
  18. "Run JavaScript to orchestrate tool calls and compose their results through `{ code }` in a confined Code Mode runtime.",
  19. "Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.",
  20. "Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by `search`. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
  21. 'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
  22. "Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
  23. "Await every call whose completion matters; pending calls are interrupted when execution ends. Run independent calls concurrently with `Promise.all`.",
  24. ].join("\n"),
  25. )
  26. })
  27. test("canonical execution distinguishes declared, model-only, and raw schema outputs", async () => {
  28. const declared = Tool.make({
  29. description: "Declared",
  30. input: Schema.Struct({ value: Schema.String }),
  31. output: Schema.Struct({ value: Schema.String }),
  32. execute: ({ value }) => Effect.succeed({ output: { value } }),
  33. })
  34. const modelOnly = Tool.make({
  35. description: "Model only",
  36. input: Schema.Struct({}),
  37. execute: () => Effect.succeed({ content: "visible only", metadata: { kind: "model" } }),
  38. })
  39. const raw = Tool.make({
  40. description: "Raw",
  41. input: {},
  42. output: {},
  43. execute: (input) => Effect.succeed({ output: input, content: "raw" }),
  44. })
  45. expect(await Effect.runPromise(Tool.execute(declared, { value: "encoded" }, context))).toEqual({
  46. output: { value: "encoded" },
  47. content: [{ type: "text", text: '{"value":"encoded"}' }],
  48. })
  49. expect(await Effect.runPromise(Tool.execute(modelOnly, {}, context))).toEqual({
  50. content: [{ type: "text", text: "visible only" }],
  51. metadata: { kind: "model" },
  52. })
  53. expect(await Effect.runPromise(Tool.execute(raw, { unchecked: true }, context))).toEqual({
  54. output: { unchecked: true },
  55. content: [{ type: "text", text: "raw" }],
  56. })
  57. })
  58. test("declared outputs cannot bypass validation and raw outputs stay JSON-compatible", async () => {
  59. const missing: Tool.Any = {
  60. description: "Missing output",
  61. input: Schema.Struct({}),
  62. output: Schema.String,
  63. execute: () => Effect.succeed({ content: "not an output" }),
  64. }
  65. const invalid: Tool.Any = {
  66. description: "Invalid raw output",
  67. input: {},
  68. output: {},
  69. execute: () => Effect.succeed({ output: 1n, content: "not JSON" }),
  70. }
  71. expect((await Effect.runPromiseExit(Tool.execute(missing, {}, context))).toString()).toContain(
  72. "Tool did not return its declared output",
  73. )
  74. expect((await Effect.runPromiseExit(Tool.execute(invalid, {}, context))).toString()).toContain(
  75. "Tool returned a non-JSON value",
  76. )
  77. })
  78. test("execute preserves successful results with visible unhandled rejections", async () => {
  79. const child = Tool.make({
  80. description: "Always fail",
  81. input: Schema.Struct({}),
  82. output: Schema.String,
  83. execute: () => Effect.fail(new Tool.Failure({ message: "Lookup refused" })),
  84. })
  85. const execute = ExecuteTool.create(new Map([["fail", { tool: child, name: "fail", permission: "fail" }]]))
  86. const result = await Effect.runPromise(Tool.execute(execute, { code: `tools.fail({}); return "done"` }, context))
  87. expect(result.metadata).toEqual({ toolCalls: [{ tool: "fail", status: "error" }] })
  88. expect(result.content).toEqual([
  89. {
  90. type: "text",
  91. text: [
  92. "done",
  93. "",
  94. "Warnings:",
  95. "- [ToolFailure] Unhandled rejection from an un-awaited promise: Lookup refused",
  96. ].join("\n"),
  97. },
  98. ])
  99. })
  100. test("execute supports callable namespace tools", async () => {
  101. const callable = Tool.make({
  102. description: "Administer Slack",
  103. input: Schema.Struct({}),
  104. output: Schema.String,
  105. execute: () => Effect.succeed({ output: "admin" }),
  106. })
  107. const child = Tool.make({
  108. description: "Create a Slack resource",
  109. input: Schema.Struct({}),
  110. output: Schema.String,
  111. execute: () => Effect.succeed({ output: "created" }),
  112. })
  113. const execute = ExecuteTool.create(
  114. new Map([
  115. ["slack_admin", { tool: callable, name: "admin", namespace: "slack", permission: "slack_admin" }],
  116. [
  117. "slack_admin_create",
  118. { tool: child, name: "create", namespace: "slack.admin", permission: "slack_admin_create" },
  119. ],
  120. ]),
  121. )
  122. const result = await Effect.runPromise(
  123. Tool.execute(
  124. execute,
  125. { code: "return [await tools.slack.admin({}), await tools.slack.admin.create({})]" },
  126. context,
  127. ),
  128. )
  129. expect(result.metadata).toEqual({
  130. toolCalls: [
  131. { tool: "slack.admin", status: "completed" },
  132. { tool: "slack.admin.create", status: "completed" },
  133. ],
  134. })
  135. expect(result.content).toEqual([{ type: "text", text: '[\n "admin",\n "created"\n]' }])
  136. })
  137. test("execute marks every admitted child call failed when interrupted", async () => {
  138. const child = Tool.make({
  139. description: "Wait forever",
  140. input: Schema.Struct({ id: Schema.Number }),
  141. output: Schema.String,
  142. execute: () => Effect.never,
  143. })
  144. const execute = ExecuteTool.create(new Map([["wait", { tool: child, name: "wait", permission: "wait" }]]))
  145. const updates: Tool.Metadata[] = []
  146. await Effect.runPromise(
  147. Effect.gen(function* () {
  148. const started = yield* Deferred.make<void>()
  149. const fiber = yield* Tool.execute(
  150. execute,
  151. { code: "return await Promise.all([tools.wait({ id: 1 }), tools.wait({ id: 2 })])" },
  152. {
  153. ...context,
  154. progress: (update) =>
  155. Effect.gen(function* () {
  156. updates.push(update)
  157. if (updates.length > 1) return
  158. yield* Deferred.succeed(started, undefined)
  159. yield* Effect.never
  160. }),
  161. },
  162. ).pipe(Effect.forkChild)
  163. yield* Deferred.await(started)
  164. yield* Effect.yieldNow
  165. yield* Effect.yieldNow
  166. yield* Fiber.interrupt(fiber)
  167. }),
  168. )
  169. expect(updates[0]).toEqual({ toolCalls: [{ tool: "wait", status: "running", input: { id: 1 } }] })
  170. expect(updates.at(-1)).toEqual({
  171. toolCalls: [
  172. { tool: "wait", status: "error", input: { id: 1 } },
  173. { tool: "wait", status: "error", input: { id: 2 } },
  174. ],
  175. })
  176. })