code-mode.test.ts 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714
  1. import { describe, expect, test } from "bun:test"
  2. import { CODE_MODE_TOOL, CodeModeTool, Parameters, describeCatalog } from "@/tool/code-mode"
  3. import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js"
  4. import type { PermissionV1 } from "@opencode-ai/core/v1/permission"
  5. import { Agent } from "@/agent/agent"
  6. import { MCP } from "@/mcp"
  7. import { Permission } from "@/permission"
  8. import { Plugin } from "@/plugin"
  9. import { Session } from "@/session/session"
  10. import { Tool } from "@/tool/tool"
  11. import * as Truncate from "@/tool/truncate"
  12. import { MessageID, SessionID } from "@/session/schema"
  13. import { Cause, Effect, Exit, Layer, Schema } from "effect"
  14. const ctx: Tool.Context = {
  15. sessionID: SessionID.make("ses_code-mode"),
  16. messageID: MessageID.make("msg_code-mode"),
  17. agent: "build",
  18. abort: new AbortController().signal,
  19. callID: "call_code_mode",
  20. messages: [],
  21. metadata: () => Effect.void,
  22. ask: () => Effect.void,
  23. }
  24. function mcpTool(
  25. name: string,
  26. handler: (args: Record<string, unknown>) => unknown,
  27. inputSchema: Record<string, unknown> = { type: "object", properties: {} },
  28. outputSchema?: Record<string, unknown>,
  29. ): MCP.McpTool {
  30. return {
  31. def: { name, description: name, inputSchema, ...(outputSchema ? { outputSchema } : {}) } as MCPToolDef,
  32. client: {
  33. callTool: async (params: { arguments?: Record<string, unknown> }) => handler(params.arguments ?? {}),
  34. } as unknown as MCP.McpTool["client"],
  35. }
  36. }
  37. function harness(input: {
  38. mcpTools: Record<string, MCP.McpTool>
  39. servers: string[]
  40. permission?: PermissionV1.Rule[]
  41. trigger?: Plugin.Interface["trigger"]
  42. }) {
  43. return Layer.mergeAll(
  44. Layer.mock(Plugin.Service, {
  45. trigger: input.trigger ?? (((_name, _input, output) => Effect.succeed(output)) as Plugin.Interface["trigger"]),
  46. }),
  47. Layer.mock(Truncate.Service, {
  48. output: (text: string) => Effect.succeed({ content: text, truncated: false as const }),
  49. }),
  50. Layer.mock(Agent.Service, {
  51. get: () => Effect.succeed({ name: "build", permission: input.permission ?? [] } as any),
  52. }),
  53. Layer.mock(Session.Service, {
  54. get: () => Effect.succeed({ permission: [] } as any),
  55. }),
  56. Layer.mock(MCP.Service, {
  57. tools: () => Effect.succeed(input.mcpTools),
  58. clients: () => Effect.succeed(Object.fromEntries(input.servers.map((name) => [name, {} as any]))),
  59. }),
  60. )
  61. }
  62. function serverNames(mcpTools: Record<string, MCP.McpTool>, servers?: string[]) {
  63. return servers ?? [...new Set(Object.keys(mcpTools).map((key) => key.split("_")[0]!))]
  64. }
  65. function build(
  66. mcpTools: Record<string, MCP.McpTool>,
  67. servers?: string[],
  68. permission?: PermissionV1.Rule[],
  69. trigger?: Plugin.Interface["trigger"],
  70. ) {
  71. const names = serverNames(mcpTools, servers)
  72. return Effect.runPromise(
  73. CodeModeTool.pipe(
  74. Effect.flatMap(Tool.init),
  75. Effect.provide(harness({ mcpTools, servers: names, permission, trigger })),
  76. ),
  77. )
  78. }
  79. function describeFor(mcpTools: Record<string, MCP.McpTool>, servers?: string[], permission: PermissionV1.Rule[] = []) {
  80. return describeCatalog(Permission.visibleTools(mcpTools, permission), serverNames(mcpTools, servers))
  81. }
  82. // Program failures die at the tool boundary; recover the defect for message assertions.
  83. async function failure(effect: Effect.Effect<unknown>) {
  84. const exit = await Effect.runPromise(effect.pipe(Effect.exit))
  85. if (Exit.isSuccess(exit)) throw new Error("expected the tool to fail")
  86. return Cause.squash(exit.cause) as Error
  87. }
  88. describe("code mode execute", () => {
  89. test("defines execute input with an Effect schema", async () => {
  90. const decode = Schema.decodeUnknownEffect(Parameters)
  91. await expect(Effect.runPromise(decode({ code: "return 1" }))).resolves.toEqual({ code: "return 1" })
  92. await expect(Effect.runPromise(decode({}))).rejects.toThrow()
  93. })
  94. test("groups multi-underscore server names by longest matching prefix", () => {
  95. const description = describeFor({ my_server_do_thing: mcpTool("do_thing", () => "") }, ["my_server"])
  96. expect(description).toContain("- my_server (1 tool)")
  97. expect(description).toContain("tools.my_server.do_thing(")
  98. })
  99. test("groupByServer uses the whole key as the server name when it has no underscore", () => {
  100. const description = describeFor({ standalone: mcpTool("standalone", () => "") }, [])
  101. expect(description).toContain("- standalone (1 tool)")
  102. expect(description).toContain("tools.standalone.standalone(")
  103. })
  104. test("describeCatalog carries the raw MCP schemas for rendering", () => {
  105. const description = describeFor(
  106. {
  107. weather_current: mcpTool(
  108. "current",
  109. () => "",
  110. { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
  111. { type: "object", properties: { tempC: { type: "number" } }, required: ["tempC"] },
  112. ),
  113. },
  114. ["weather"],
  115. )
  116. expect(description).toContain("tools.weather.current(input: { city: string }): Promise<{ tempC: number }>")
  117. })
  118. test("the static base description carries no catalog; the registry appends it", async () => {
  119. const tool = await build({ github_list_issues: mcpTool("list_issues", () => "") })
  120. expect(tool.id).toBe(CODE_MODE_TOOL)
  121. expect(tool.description).toContain("confined runtime")
  122. expect(tool.description).not.toContain("Available tools")
  123. expect(tool.description).not.toContain("list_issues")
  124. })
  125. test("small catalogs inline every full signature in the appended catalog", () => {
  126. const description = describeFor({
  127. github_create_issue: mcpTool("create_issue", () => "", {
  128. type: "object",
  129. properties: { title: { type: "string" }, body: { type: "string" } },
  130. required: ["title"],
  131. }),
  132. github_list_issues: mcpTool("list_issues", () => ""),
  133. linear_search: mcpTool("search", () => ""),
  134. })
  135. expect(description).toContain("Available tools (COMPLETE list")
  136. expect(description).toContain("- github (2 tools)")
  137. expect(description).toContain("- linear (1 tool)")
  138. expect(description).toContain(
  139. "tools.github.create_issue(input: { title: string; body?: string }): Promise<unknown>",
  140. )
  141. expect(description).toContain("tools.github.list_issues(")
  142. expect(description).toContain("tools.linear.search(")
  143. expect(description).toContain("tools.linear.search(input: {}): Promise<unknown>")
  144. expect(description).not.toContain("$codemode")
  145. expect(description).not.toContain("Browse one namespace")
  146. expect(description).toContain("## Workflow")
  147. expect(description).toContain("1. Pick a tool from the list under `## Available tools`")
  148. expect(description).toContain(
  149. '`const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string',
  150. )
  151. expect(description).toContain("Return only the fields you need")
  152. expect(description).not.toContain("total_count")
  153. })
  154. test("signatures render the declared outputSchema as the return type", () => {
  155. const description = describeFor({
  156. weather_current: mcpTool(
  157. "current",
  158. () => "",
  159. { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
  160. {
  161. type: "object",
  162. properties: { tempC: { type: "number" }, summary: { type: "string" } },
  163. required: ["tempC"],
  164. },
  165. ),
  166. })
  167. expect(description).toContain(
  168. "tools.weather.current(input: { city: string }): Promise<{ tempC: number; summary?: string }>",
  169. )
  170. })
  171. test("large catalogs inline a budgeted PARTIAL list plus runtime search", async () => {
  172. const tools: Record<string, MCP.McpTool> = {}
  173. const filler = "a searchable description of this operation that consumes catalog budget ".repeat(3)
  174. for (let i = 0; i < 150; i++) {
  175. tools[`alpha_op_${i}`] = {
  176. def: {
  177. name: `op_${i}`,
  178. description: `${filler}${i}`,
  179. inputSchema: { type: "object", properties: { value: { type: "string" }, count: { type: "number" } } },
  180. } as MCPToolDef,
  181. client: { callTool: async () => ({ content: [] }) } as unknown as MCP.McpTool["client"],
  182. }
  183. }
  184. tools["zeta_only_tool"] = mcpTool("only_tool", () => "", {
  185. type: "object",
  186. properties: { topic: { type: "string", description: "Subject to look up" } },
  187. required: ["topic"],
  188. })
  189. const description = describeFor(tools, ["alpha", "zeta"])
  190. expect(description).toContain("Available tools (PARTIAL - ")
  191. expect(description).toMatch(/- alpha \(150 tools, \d+ shown\)/)
  192. expect(description).toContain("- zeta (1 tool)\n")
  193. expect(description).toContain("tools.zeta.only_tool(input: { topic: string }): Promise<unknown>")
  194. expect(description).toContain("tools.$codemode.search(")
  195. expect(description).toContain("1. Find a tool (skip when it is already listed below)")
  196. expect(description).toContain(
  197. '- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.',
  198. )
  199. expect(description).not.toContain("total_count")
  200. expect(description).toContain("tools.alpha.op_0(")
  201. expect(description).not.toContain("tools.alpha.op_99(")
  202. const tool = await build(tools, ["alpha", "zeta"])
  203. const out = await Effect.runPromise(
  204. tool.execute({ code: "return await tools.$codemode.search({ query: 'only tool', limit: 3 })" }, ctx),
  205. )
  206. const result = JSON.parse(out.output)
  207. expect(result.items.map((i: any) => i.path)).toContain("tools.zeta.only_tool")
  208. expect(result.items[0].signature).toContain("tools.")
  209. const signature = result.items.find((i: any) => i.path === "tools.zeta.only_tool").signature
  210. expect(signature).toContain("tools.zeta.only_tool(input: {\n")
  211. expect(signature).toContain(" /** Subject to look up */\n topic: string")
  212. expect(description).not.toContain("/**")
  213. expect(out.metadata.toolCalls).toEqual([
  214. { tool: "$codemode.search", status: "completed", input: { query: "only tool", limit: 3 } },
  215. ])
  216. })
  217. test("runs plain JavaScript and returns the value as text", async () => {
  218. const tool = await build({})
  219. const output = await Effect.runPromise(tool.execute({ code: "return 1 + 2" }, ctx))
  220. expect(output.output).toBe("3")
  221. expect(output.metadata.toolCalls).toEqual([])
  222. })
  223. test("Object.keys(tools) enumerates the MCP server namespaces", async () => {
  224. const tool = await build({
  225. github_list_issues: mcpTool("list_issues", () => ""),
  226. linear_search: mcpTool("search", () => ""),
  227. })
  228. const output = await Effect.runPromise(
  229. tool.execute(
  230. { code: "const namespaces = Object.keys(tools); return { namespaces, count: namespaces.length }" },
  231. ctx,
  232. ),
  233. )
  234. expect(JSON.parse(output.output)).toEqual({ namespaces: ["github", "linear"], count: 2 })
  235. })
  236. test("calls a namespaced MCP tool and flows its text result back into the program", async () => {
  237. const seen: Record<string, unknown>[] = []
  238. const tool = await build({
  239. greeter_hello: mcpTool("hello", (args) => {
  240. seen.push(args)
  241. return { content: [{ type: "text", text: `hello ${args.name}` }] }
  242. }),
  243. })
  244. const output = await Effect.runPromise(
  245. tool.execute({ code: "const r = await tools.greeter.hello({ name: 'world' }); return r.toUpperCase()" }, ctx),
  246. )
  247. expect(seen).toEqual([{ name: "world" }])
  248. expect(output.output).toBe("HELLO WORLD")
  249. expect(output.metadata.toolCalls).toEqual([
  250. { tool: "greeter.hello", status: "completed", input: { name: "world" } },
  251. ])
  252. })
  253. test("exposes structured content as native data and composes multiple calls", async () => {
  254. const tool = await build({
  255. math_add: mcpTool("add", (args) => ({
  256. content: [],
  257. structuredContent: { sum: (args.a as number) + (args.b as number) },
  258. })),
  259. })
  260. const output = await Effect.runPromise(
  261. tool.execute(
  262. {
  263. code: `
  264. const first = await tools.math.add({ a: 1, b: 2 })
  265. const second = await tools.math.add({ a: first.sum, b: 10 })
  266. return { total: second.sum }
  267. `,
  268. },
  269. ctx,
  270. ),
  271. )
  272. expect(JSON.parse(output.output)).toEqual({ total: 13 })
  273. expect(output.metadata.toolCalls).toEqual([
  274. { tool: "math.add", status: "completed", input: { a: 1, b: 2 } },
  275. { tool: "math.add", status: "completed", input: { a: 3, b: 10 } },
  276. ])
  277. })
  278. test("runs tool calls in parallel with Promise.all", async () => {
  279. const tool = await build({
  280. echo_one: mcpTool("one", () => ({ content: [{ type: "text", text: "1" }] })),
  281. echo_two: mcpTool("two", () => ({ content: [{ type: "text", text: "2" }] })),
  282. })
  283. const output = await Effect.runPromise(
  284. tool.execute(
  285. { code: "const [a, b] = await Promise.all([tools.echo.one({}), tools.echo.two({})]); return a + b" },
  286. ctx,
  287. ),
  288. )
  289. expect(output.output).toBe("12")
  290. expect(output.metadata.toolCalls.map((c) => c.tool).sort()).toEqual(["echo.one", "echo.two"])
  291. expect(output.metadata.toolCalls.every((c) => c.status === "completed")).toBe(true)
  292. })
  293. test("a program failure fails the tool with a readable error", async () => {
  294. const tool = await build({})
  295. const error = await failure(tool.execute({ code: "throw new Error('boom')" }, ctx))
  296. expect(error.message).toBe("Uncaught: boom")
  297. })
  298. test("reports an unknown tool as a failed execution", async () => {
  299. const tool = await build({ known_tool: mcpTool("tool", () => "ok") })
  300. const error = await failure(tool.execute({ code: "return await tools.known.missing({})" }, ctx))
  301. expect(error.message).toContain("Unknown tool 'known.missing'")
  302. })
  303. test("propagates an MCP tool error into the program as a catchable failure", async () => {
  304. const tool = await build({
  305. bad_tool: mcpTool("tool", () => ({ isError: true, content: [{ type: "text", text: "server exploded" }] })),
  306. })
  307. const output = await Effect.runPromise(
  308. tool.execute({ code: "try { await tools.bad.tool({}) } catch (e) { return 'caught: ' + e.message }" }, ctx),
  309. )
  310. expect(output.output).toBe("caught: server exploded")
  311. })
  312. test("asks permission before each child tool call", async () => {
  313. const asked: unknown[] = []
  314. const permissionCtx: Tool.Context = { ...ctx, ask: (req) => Effect.sync(() => void asked.push(req)) }
  315. const ok = () => ({ content: [{ type: "text", text: "ok" }] })
  316. const tool = await build({ a_tool: mcpTool("a", ok), b_tool: mcpTool("b", ok) })
  317. await Effect.runPromise(
  318. tool.execute({ code: "await tools.a.tool({}); await tools.b.tool({}); return 'done'" }, permissionCtx),
  319. )
  320. expect(asked.map((req: any) => req.permission)).toEqual(["a_tool", "b_tool"])
  321. })
  322. test("a denied permission fails the child call with a catchable message, not the whole execute", async () => {
  323. const denyCtx: Tool.Context = { ...ctx, ask: () => Effect.die(new Error("permission denied by user")) }
  324. const called: string[] = []
  325. const tool = await build({
  326. a_tool: mcpTool("a", () => {
  327. called.push("a")
  328. return { content: [{ type: "text", text: "ok" }] }
  329. }),
  330. })
  331. const output = await Effect.runPromise(
  332. tool.execute({ code: "try { await tools.a.tool({}) } catch (e) { return 'denied: ' + e.message }" }, denyCtx),
  333. )
  334. expect(output.output).toBe("denied: permission denied by user")
  335. expect(output.metadata.error).toBeUndefined()
  336. expect(called).toEqual([])
  337. expect(output.metadata.toolCalls).toEqual([{ tool: "a.tool", status: "error" }])
  338. })
  339. test("child calls fire plugin tool.execute hooks with the MCP key and synthetic parent/N call ids", async () => {
  340. const events: { name: string; input: any; output: any }[] = []
  341. const trigger = ((name: unknown, input: unknown, output: unknown) =>
  342. Effect.sync(() => {
  343. events.push({ name: name as string, input, output })
  344. return output
  345. })) as Plugin.Interface["trigger"]
  346. const tool = await build(
  347. {
  348. a_tool: mcpTool("a", () => ({ content: [{ type: "text", text: "one" }] })),
  349. b_tool: mcpTool("b", () => ({ content: [{ type: "text", text: "two" }] })),
  350. },
  351. undefined,
  352. undefined,
  353. trigger,
  354. )
  355. const out = await Effect.runPromise(
  356. tool.execute({ code: "await tools.a.tool({ x: 1 }); await tools.b.tool({}); return 'done'" }, ctx),
  357. )
  358. expect(out.output).toBe("done")
  359. expect(events.map((e) => [e.name, e.input.tool, e.input.callID])).toEqual([
  360. ["tool.execute.before", "a_tool", "call_code_mode/1"],
  361. ["tool.execute.after", "a_tool", "call_code_mode/1"],
  362. ["tool.execute.before", "b_tool", "call_code_mode/2"],
  363. ["tool.execute.after", "b_tool", "call_code_mode/2"],
  364. ])
  365. const [before, after] = events
  366. expect(before!.input.sessionID).toBe(ctx.sessionID)
  367. expect(before!.output).toEqual({ args: { x: 1 } })
  368. expect(after!.input.args).toEqual({ x: 1 })
  369. expect(after!.output).toEqual({ content: [{ type: "text", text: "one" }] })
  370. })
  371. test("a failing before hook fails only that child call as a catchable in-program error", async () => {
  372. const trigger = ((name: unknown, input: any, output: unknown) => {
  373. if (name === "tool.execute.before" && input.tool === "a_tool") return Effect.die(new Error("hook exploded"))
  374. return Effect.succeed(output)
  375. }) as Plugin.Interface["trigger"]
  376. const called: string[] = []
  377. const record = (name: string) => () => {
  378. called.push(name)
  379. return { content: [{ type: "text", text: "ok" }] }
  380. }
  381. const tool = await build(
  382. { a_tool: mcpTool("a", record("a")), b_tool: mcpTool("b", record("b")) },
  383. undefined,
  384. undefined,
  385. trigger,
  386. )
  387. const out = await Effect.runPromise(
  388. tool.execute(
  389. {
  390. code: `
  391. let caught
  392. try { await tools.a.tool({}) } catch (e) { caught = e.message }
  393. const r = await tools.b.tool({})
  394. return caught + " / " + r
  395. `,
  396. },
  397. ctx,
  398. ),
  399. )
  400. expect(out.metadata.error).toBeUndefined()
  401. expect(out.output).toBe("hook exploded / ok")
  402. expect(called).toEqual(["b"])
  403. })
  404. test("streams live per-call metadata as a call starts and finishes", async () => {
  405. const snapshots: Array<{ toolCalls: { tool: string; status: string; input?: Record<string, unknown> }[] }> = []
  406. const recordingCtx: Tool.Context = {
  407. ...ctx,
  408. metadata: (val: any) => Effect.sync(() => void snapshots.push(val.metadata)),
  409. }
  410. const tool = await build({ greeter_hello: mcpTool("hello", () => ({ content: [{ type: "text", text: "hi" }] })) })
  411. await Effect.runPromise(
  412. tool.execute({ code: "await tools.greeter.hello({ name: 'Ada' }); return 'done'" }, recordingCtx),
  413. )
  414. expect(snapshots).toContainEqual({
  415. toolCalls: [{ tool: "greeter.hello", status: "running", input: { name: "Ada" } }],
  416. })
  417. expect(snapshots).toContainEqual({
  418. toolCalls: [{ tool: "greeter.hello", status: "completed", input: { name: "Ada" } }],
  419. })
  420. })
  421. test("marks a failed child call as error in the live metadata", async () => {
  422. const snapshots: Array<{ toolCalls: { tool: string; status: string; input?: Record<string, unknown> }[] }> = []
  423. const recordingCtx: Tool.Context = {
  424. ...ctx,
  425. metadata: (val: any) => Effect.sync(() => void snapshots.push(val.metadata)),
  426. }
  427. const tool = await build({
  428. bad_tool: mcpTool("tool", () => ({ isError: true, content: [{ type: "text", text: "boom" }] })),
  429. })
  430. await Effect.runPromise(
  431. tool.execute(
  432. { code: "try { await tools.bad.tool({ reason: 'test' }) } catch (e) { return 'caught' }" },
  433. recordingCtx,
  434. ),
  435. )
  436. expect(snapshots).toContainEqual({ toolCalls: [{ tool: "bad.tool", status: "error", input: { reason: "test" } }] })
  437. })
  438. test("accumulates stripped media as execute attachments the sandbox never sees", async () => {
  439. const tool = await build({
  440. shot_take: mcpTool("take", () => ({
  441. content: [{ type: "image", data: "PNGDATA", mimeType: "image/png" }],
  442. structuredContent: { name: "shot.png" },
  443. })),
  444. })
  445. const out = await Effect.runPromise(tool.execute({ code: "return await tools.shot.take({})" }, ctx))
  446. expect(JSON.parse(out.output)).toEqual({ name: "shot.png" })
  447. expect(out.attachments).toEqual([{ type: "file", mime: "image/png", url: "data:image/png;base64,PNGDATA" }])
  448. expect(out.output).not.toContain("PNGDATA")
  449. })
  450. test("a media-only result returns a text marker so the program knows it succeeded", async () => {
  451. const tool = await build({
  452. shot_take: mcpTool("take", () => ({ content: [{ type: "image", data: "PNGDATA", mimeType: "image/png" }] })),
  453. })
  454. const out = await Effect.runPromise(tool.execute({ code: "return await tools.shot.take({})" }, ctx))
  455. expect(out.output).toBe("[1 image attached to the result]")
  456. expect(out.attachments).toEqual([{ type: "file", mime: "image/png", url: "data:image/png;base64,PNGDATA" }])
  457. })
  458. test("media-only markers distinguish all-image from mixed attachments", async () => {
  459. const tool = await build({
  460. media_images: mcpTool("images", () => ({
  461. content: [
  462. { type: "image", data: "PNG1", mimeType: "image/png" },
  463. { type: "image", data: "PNG2", mimeType: "image/png" },
  464. ],
  465. })),
  466. media_mixed: mcpTool("mixed", () => ({
  467. content: [
  468. { type: "image", data: "PNG3", mimeType: "image/png" },
  469. { type: "resource", resource: { uri: "file:///tmp/report.pdf", mimeType: "application/pdf", blob: "PDF1" } },
  470. ],
  471. })),
  472. })
  473. const out = await Effect.runPromise(
  474. tool.execute(
  475. {
  476. code: `
  477. const images = await tools.media.images({})
  478. const mixed = await tools.media.mixed({})
  479. return { images, mixed }
  480. `,
  481. },
  482. ctx,
  483. ),
  484. )
  485. expect(JSON.parse(out.output)).toEqual({
  486. images: "[2 images attached to the result]",
  487. mixed: "[2 files attached to the result]",
  488. })
  489. expect(out.output).not.toContain("PNG")
  490. expect(out.attachments).toEqual([
  491. { type: "file", mime: "image/png", url: "data:image/png;base64,PNG1" },
  492. { type: "file", mime: "image/png", url: "data:image/png;base64,PNG2" },
  493. { type: "file", mime: "image/png", url: "data:image/png;base64,PNG3" },
  494. { type: "file", mime: "application/pdf", url: "data:application/pdf;base64,PDF1", filename: "report.pdf" },
  495. ])
  496. })
  497. test("resource links flow to the program as text, never as attachments", async () => {
  498. const tool = await build({
  499. docs_find: mcpTool("find", () => ({
  500. content: [
  501. {
  502. type: "resource_link",
  503. uri: "https://example.com/guide.pdf",
  504. name: "guide.pdf",
  505. mimeType: "application/pdf",
  506. },
  507. { type: "resource_link", uri: "file:///tmp/notes.md", name: "notes.md" },
  508. ],
  509. })),
  510. })
  511. const out = await Effect.runPromise(tool.execute({ code: "return await tools.docs.find({})" }, ctx))
  512. expect(out.output).toBe("guide.pdf: https://example.com/guide.pdf\nnotes.md: file:///tmp/notes.md")
  513. expect(out.attachments).toBeUndefined()
  514. })
  515. test("attachments still flow when the program returns something else entirely", async () => {
  516. const tool = await build({
  517. shot_take: mcpTool("take", () => ({ content: [{ type: "image", data: "PNGDATA", mimeType: "image/png" }] })),
  518. })
  519. const out = await Effect.runPromise(tool.execute({ code: "await tools.shot.take({}); return 'captured'" }, ctx))
  520. expect(out.output).toBe("captured")
  521. expect(out.attachments).toHaveLength(1)
  522. })
  523. test("isolates the sandbox from host globals", async () => {
  524. const tool = await build({})
  525. const error = await failure(tool.execute({ code: "return process.env" }, ctx))
  526. expect(error.message).toContain("process")
  527. })
  528. test("cancelling via ctx.abort interrupts the running program", async () => {
  529. const controller = new AbortController()
  530. const tool = await build({
  531. host_trigger: mcpTool("trigger", () => {
  532. controller.abort()
  533. return new Promise(() => {})
  534. }),
  535. })
  536. const output = await Effect.runPromise(
  537. tool.execute(
  538. { code: "try { await tools.host.trigger({}) } catch {} while (true) {}" },
  539. { ...ctx, abort: controller.signal },
  540. ),
  541. )
  542. expect(output.output).toBe("Execution cancelled.")
  543. expect(output.metadata.error).toBe(true)
  544. expect(output.metadata.toolCalls).toEqual([{ tool: "host.trigger", status: "running" }])
  545. })
  546. test("a pre-aborted signal cancels before the program runs", async () => {
  547. const controller = new AbortController()
  548. controller.abort()
  549. const ran: string[] = []
  550. const tool = await build({ host_touch: mcpTool("touch", () => (ran.push("called"), "ok")) })
  551. const output = await Effect.runPromise(
  552. tool.execute({ code: "return await tools.host.touch({})" }, { ...ctx, abort: controller.signal }),
  553. )
  554. expect(output.output).toBe("Execution cancelled.")
  555. expect(ran).toEqual([])
  556. })
  557. test("leaves oversized results to OpenCode's native tool-output truncation", async () => {
  558. const tool = await build({})
  559. const output = await Effect.runPromise(tool.execute({ code: "return 'x'.repeat(40000)" }, ctx))
  560. expect(output.metadata.error).toBeUndefined()
  561. expect(output.output).not.toContain("[result truncated:")
  562. expect(output.output.length).toBeGreaterThanOrEqual(40_000)
  563. })
  564. test("appends logs after the result on success and after the message on error", async () => {
  565. const tool = await build({})
  566. const ok = await Effect.runPromise(
  567. tool.execute({ code: "console.log('step one'); console.warn('careful'); return 'done'" }, ctx),
  568. )
  569. expect(ok.output).toBe("done\n\nLogs:\nstep one\n[warn] careful")
  570. const error = await failure(tool.execute({ code: "console.log('before the throw'); throw new Error('boom')" }, ctx))
  571. expect(error.message).toContain("Uncaught: boom")
  572. expect(error.message).toContain("Logs:\nbefore the throw")
  573. })
  574. })
  575. describe("code mode permission visibility", () => {
  576. const deny = (permission: string): PermissionV1.Rule => ({ permission, pattern: "*", action: "deny" })
  577. const askRule = (permission: string): PermissionV1.Rule => ({ permission, pattern: "*", action: "ask" })
  578. const ok = () => ({ content: [{ type: "text", text: "ok" }] })
  579. test("a hard-denied tool never enters the catalog or its search index", () => {
  580. const mcpTools = {
  581. github_create_issue: mcpTool("create_issue", ok),
  582. github_list_issues: mcpTool("list_issues", ok),
  583. }
  584. const description = describeFor(mcpTools, ["github"], [deny("github_create_issue")])
  585. expect(description).toContain("tools.github.list_issues(")
  586. expect(description).not.toContain("create_issue")
  587. expect(description).toContain("- github (1 tool)")
  588. })
  589. test("an ask-level tool stays fully visible in the catalog", () => {
  590. const mcpTools = {
  591. github_create_issue: mcpTool("create_issue", ok),
  592. github_list_issues: mcpTool("list_issues", ok),
  593. }
  594. const description = describeFor(mcpTools, ["github"], [askRule("github_create_issue")])
  595. expect(description).toContain("tools.github.create_issue(")
  596. expect(description).toContain("tools.github.list_issues(")
  597. expect(description).toContain("- github (2 tools)")
  598. })
  599. test("a hard-denied tool is not dispatchable: the program gets the unknown-tool diagnostic", async () => {
  600. const called: string[] = []
  601. const tool = await build(
  602. {
  603. github_create_issue: mcpTool("create_issue", () => {
  604. called.push("create_issue")
  605. return ok()
  606. }),
  607. github_list_issues: mcpTool("list_issues", ok),
  608. },
  609. ["github"],
  610. [deny("github_create_issue")],
  611. )
  612. const denied = await failure(tool.execute({ code: "return await tools.github.create_issue({ title: 'x' })" }, ctx))
  613. expect(denied.message).toContain("Unknown tool 'github.create_issue'")
  614. expect(denied.message).not.toContain("permission")
  615. expect(called).toEqual([])
  616. const allowed = await Effect.runPromise(tool.execute({ code: "return await tools.github.list_issues({})" }, ctx))
  617. expect(allowed.metadata.error).toBeUndefined()
  618. expect(allowed.output).toBe("ok")
  619. })
  620. test("an ask-level tool remains callable and still prompts via ctx.ask", async () => {
  621. const asked: string[] = []
  622. const askCtx: Tool.Context = { ...ctx, ask: (req) => Effect.sync(() => void asked.push(req.permission)) }
  623. const tool = await build(
  624. { github_list_issues: mcpTool("list_issues", ok) },
  625. ["github"],
  626. [askRule("github_list_issues")],
  627. )
  628. const out = await Effect.runPromise(tool.execute({ code: "return await tools.github.list_issues({})" }, askCtx))
  629. expect(out.output).toBe("ok")
  630. expect(asked).toEqual(["github_list_issues"])
  631. })
  632. test("Permission.visibleTools hides only hard denies, matching Permission.disabled", () => {
  633. const tools = { a_tool: 1, b_tool: 2, c_tool: 3 }
  634. const visible = Permission.visibleTools(tools, [
  635. deny("a_tool"),
  636. askRule("b_tool"),
  637. { permission: "c_tool", pattern: "something", action: "deny" },
  638. ])
  639. expect(Object.keys(visible)).toEqual(["b_tool", "c_tool"])
  640. })
  641. })