code-mode.test.ts 30 KB

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