registry.test.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572
  1. import { afterEach, describe, expect } from "bun:test"
  2. import path from "path"
  3. import fs from "fs/promises"
  4. import { fileURLToPath, pathToFileURL } from "url"
  5. import { Effect, Layer, Result, Schema } from "effect"
  6. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  7. import { ToolRegistry } from "@/tool/registry"
  8. import { Tool } from "@/tool/tool"
  9. import { disposeAllInstances, TestInstance } from "../fixture/fixture"
  10. import { testEffect } from "../lib/effect"
  11. import { TestConfig } from "../fixture/config"
  12. import { Config } from "@/config/config"
  13. import { Plugin } from "@/plugin"
  14. import { Agent } from "@/agent/agent"
  15. import { InstanceState } from "@/effect/instance-state"
  16. import { ToolJsonSchema } from "@/tool/json-schema"
  17. import { MessageID, SessionID } from "@/session/schema"
  18. import { RuntimeFlags } from "@/effect/runtime-flags"
  19. import { ProviderV2 } from "@kirincode-ai/core/provider"
  20. import { ModelV2 } from "@kirincode-ai/core/model"
  21. import { MCP } from "@/mcp"
  22. import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js"
  23. const configLayer = TestConfig.layer({
  24. directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".kirincode")])),
  25. })
  26. // Fake Plugin.Service that returns a single plugin whose `tool` map contains
  27. // one definition with `args: undefined`. Used to exercise the plugin entry
  28. // point of `fromPlugin` for the #27451 / #27630 regression.
  29. const brokenPluginLayer = Layer.succeed(
  30. Plugin.Service,
  31. Plugin.Service.of({
  32. init: () => Effect.void,
  33. trigger: ((_name: unknown, _input: unknown, output: unknown) =>
  34. Effect.succeed(output)) as Plugin.Interface["trigger"],
  35. list: () =>
  36. Effect.succeed([
  37. {
  38. tool: {
  39. broken_plugin_tool: {
  40. description: "plugin tool with missing args",
  41. args: undefined as unknown as Record<string, never>,
  42. execute: async () => "ok",
  43. },
  44. },
  45. },
  46. ]),
  47. }),
  48. )
  49. const root = LayerNode.group([ToolRegistry.node, Agent.node])
  50. const replacements = [
  51. [Config.node, configLayer],
  52. [RuntimeFlags.node, RuntimeFlags.layer()],
  53. ] as const
  54. const it = testEffect(LayerNode.compile(root, replacements))
  55. const withCodeMode = testEffect(
  56. LayerNode.compile(root, [
  57. [Config.node, configLayer],
  58. [RuntimeFlags.node, RuntimeFlags.layer({ experimentalCodeMode: true })],
  59. [
  60. MCP.node,
  61. Layer.mock(MCP.Service, {
  62. tools: () =>
  63. Effect.succeed({
  64. weather_current: {
  65. def: {
  66. name: "current",
  67. description: "current weather",
  68. inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
  69. } as MCPToolDef,
  70. client: {} as MCP.McpTool["client"],
  71. },
  72. }),
  73. clients: () => Effect.succeed({ weather: {} as any }),
  74. }),
  75. ],
  76. ]),
  77. )
  78. const withEmptyCodeMode = testEffect(
  79. LayerNode.compile(root, [
  80. [Config.node, configLayer],
  81. [RuntimeFlags.node, RuntimeFlags.layer({ experimentalCodeMode: true })],
  82. [
  83. MCP.node,
  84. Layer.mock(MCP.Service, {
  85. tools: () => Effect.succeed({}),
  86. clients: () => Effect.succeed({}),
  87. }),
  88. ],
  89. ]),
  90. )
  91. const withBrokenPlugin = testEffect(LayerNode.compile(root, [...replacements, [Plugin.node, brokenPluginLayer]]))
  92. afterEach(async () => {
  93. await disposeAllInstances()
  94. })
  95. describe("tool.registry", () => {
  96. it.instance("does not expose task_status", () =>
  97. Effect.gen(function* () {
  98. const registry = yield* ToolRegistry.Service
  99. const ids = yield* registry.ids()
  100. expect(ids).not.toContain("task_status")
  101. }),
  102. )
  103. it.instance("does not expose execute unless code mode is enabled", () =>
  104. Effect.gen(function* () {
  105. const registry = yield* ToolRegistry.Service
  106. const ids = yield* registry.ids()
  107. expect(ids).not.toContain("execute")
  108. }),
  109. )
  110. withCodeMode.instance("exposes execute when code mode is enabled", () =>
  111. Effect.gen(function* () {
  112. const registry = yield* ToolRegistry.Service
  113. const agents = yield* Agent.Service
  114. const ids = yield* registry.ids()
  115. const tools = yield* registry.tools({
  116. providerID: ProviderV2.ID.kirincode,
  117. modelID: ModelV2.ID.make("test"),
  118. agent: yield* agents.defaultInfo(),
  119. })
  120. const execute = tools.find((tool) => tool.id === "execute")
  121. expect(ids).toContain("execute")
  122. expect(tools.map((tool) => tool.id)).toContain("execute")
  123. expect(execute?.description).toContain("tools.weather.current(input: {\n city: string,\n})")
  124. }),
  125. )
  126. withEmptyCodeMode.instance("does not expose execute when code mode has no visible tools", () =>
  127. Effect.gen(function* () {
  128. const registry = yield* ToolRegistry.Service
  129. const agents = yield* Agent.Service
  130. const tools = yield* registry.tools({
  131. providerID: ProviderV2.ID.kirincode,
  132. modelID: ModelV2.ID.make("test"),
  133. agent: yield* agents.defaultInfo(),
  134. })
  135. expect(tools.map((tool) => tool.id)).not.toContain("execute")
  136. }),
  137. )
  138. it.instance("hides task background parameter unless experimental background subagents are enabled", () =>
  139. Effect.gen(function* () {
  140. const registry = yield* ToolRegistry.Service
  141. const agent = yield* Agent.Service
  142. const build = yield* agent.get("build")
  143. if (!build) throw new Error("build agent not found")
  144. const task = (yield* registry.tools({
  145. providerID: ProviderV2.ID.kirincode,
  146. modelID: ModelV2.ID.make("test"),
  147. agent: build,
  148. })).find((tool) => tool.id === "task")
  149. expect(task?.jsonSchema).toBeDefined()
  150. expect((task?.jsonSchema?.properties as Record<string, unknown> | undefined)?.background).toBeUndefined()
  151. }),
  152. )
  153. it.instance("loads tools from .kirincode/tool (singular)", () =>
  154. Effect.gen(function* () {
  155. const test = yield* TestInstance
  156. const kirincode = path.join(test.directory, ".kirincode")
  157. const tool = path.join(opencode, "tool")
  158. yield* Effect.promise(() => fs.mkdir(tool, { recursive: true }))
  159. yield* Effect.promise(() =>
  160. Bun.write(
  161. path.join(tool, "hello.ts"),
  162. [
  163. "export default {",
  164. " description: 'hello tool',",
  165. " args: {},",
  166. " execute: async () => {",
  167. " return 'hello world'",
  168. " },",
  169. "}",
  170. "",
  171. ].join("\n"),
  172. ),
  173. )
  174. const registry = yield* ToolRegistry.Service
  175. const ids = yield* registry.ids()
  176. expect(ids).toContain("hello")
  177. }),
  178. )
  179. it.instance("ignores non-tool exports in .kirincode/tool files", () =>
  180. Effect.gen(function* () {
  181. const test = yield* TestInstance
  182. const tool = path.join(test.directory, ".kirincode", "tool")
  183. yield* Effect.promise(() => fs.mkdir(tool, { recursive: true }))
  184. yield* Effect.promise(() =>
  185. Bun.write(
  186. path.join(tool, "mixed.ts"),
  187. [
  188. "export const helper = 'not a tool'",
  189. "export default {",
  190. " description: 'mixed tool',",
  191. " args: {},",
  192. " execute: async () => 'ok',",
  193. "}",
  194. "",
  195. ].join("\n"),
  196. ),
  197. )
  198. const registry = yield* ToolRegistry.Service
  199. const ids = yield* registry.ids()
  200. expect(ids).toContain("mixed")
  201. expect(ids).not.toContain("mixed_helper")
  202. }),
  203. )
  204. // Regression for #27451 / #27630: a custom tool that omits `args` must not
  205. // crash registry initialization with
  206. // `Object.entries requires that input parameter not be null or undefined`.
  207. // Pre-1.14.49 the code path was `z.object(def.args)`, and `z.object(undefined)`
  208. // silently produced an empty schema — so the tool registered as no-args.
  209. // Preserve that tolerance.
  210. it.instance("tolerates a custom tool exporting null/undefined args (no-args fallback)", () =>
  211. Effect.gen(function* () {
  212. const test = yield* TestInstance
  213. const tool = path.join(test.directory, ".kirincode", "tool")
  214. yield* Effect.promise(() => fs.mkdir(tool, { recursive: true }))
  215. yield* Effect.promise(() =>
  216. Bun.write(
  217. path.join(tool, "noargs.ts"),
  218. [
  219. "export default {",
  220. " description: 'tool with no args',",
  221. " args: undefined,",
  222. " execute: async () => 'ok',",
  223. "}",
  224. "",
  225. ].join("\n"),
  226. ),
  227. )
  228. const registry = yield* ToolRegistry.Service
  229. const ids = yield* registry.ids()
  230. // Built-in tools must still load — a single malformed custom tool must
  231. // not poison the whole registry.
  232. expect(ids).toContain("read")
  233. const loaded = (yield* registry.all()).find((t) => t.id === "noargs")
  234. if (!loaded) throw new Error("noargs tool was not loaded")
  235. expect(loaded.jsonSchema).toMatchObject({ type: "object", properties: {} })
  236. }),
  237. )
  238. // Same regression, plugin entry point. The original reports (#27451, #27630)
  239. // came in through `plugin.list()` — `oh-my-opencode` was registering a tool
  240. // with `args: undefined` and crashing every message submit. The file-scan
  241. // and plugin-list loops both funnel through `fromPlugin`, but covering both
  242. // entry points means a future refactor that splits them won't silently lose
  243. // protection.
  244. withBrokenPlugin.instance("tolerates a plugin tool registered with null/undefined args", () =>
  245. Effect.gen(function* () {
  246. const registry = yield* ToolRegistry.Service
  247. const ids = yield* registry.ids()
  248. expect(ids).toContain("read")
  249. expect(ids).toContain("broken_plugin_tool")
  250. }),
  251. )
  252. it.instance("loads tools from .kirincode/tools (plural)", () =>
  253. Effect.gen(function* () {
  254. const test = yield* TestInstance
  255. const kirincode = path.join(test.directory, ".kirincode")
  256. const tools = path.join(opencode, "tools")
  257. yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
  258. yield* Effect.promise(() =>
  259. Bun.write(
  260. path.join(tools, "hello.ts"),
  261. [
  262. "export default {",
  263. " description: 'hello tool',",
  264. " args: {},",
  265. " execute: async () => {",
  266. " return 'hello world'",
  267. " },",
  268. "}",
  269. "",
  270. ].join("\n"),
  271. ),
  272. )
  273. const registry = yield* ToolRegistry.Service
  274. const ids = yield* registry.ids()
  275. expect(ids).toContain("hello")
  276. }),
  277. )
  278. it.instance("loads Zod-schema custom tools with JSON Schema and validation", () =>
  279. Effect.gen(function* () {
  280. const test = yield* TestInstance
  281. const customTools = path.join(test.directory, ".kirincode", "tools")
  282. const pluginTool = pathToFileURL(path.resolve(import.meta.dir, "../../../plugin/src/tool.ts")).href
  283. yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true }))
  284. yield* Effect.promise(() =>
  285. Bun.write(
  286. path.join(customTools, "sql.ts"),
  287. [
  288. `import { tool } from ${JSON.stringify(pluginTool)}`,
  289. "export default tool({",
  290. " description: 'query database',",
  291. " args: { query: tool.schema.string().describe('SQL query to execute') },",
  292. " execute: async ({ query }) => query,",
  293. "})",
  294. "",
  295. ].join("\n"),
  296. ),
  297. )
  298. const registry = yield* ToolRegistry.Service
  299. const loaded = (yield* registry.all()).find((tool) => tool.id === "sql")
  300. if (!loaded) throw new Error("custom sql tool was not loaded")
  301. expect(loaded?.jsonSchema).toMatchObject({
  302. type: "object",
  303. properties: {
  304. query: { type: "string", description: "SQL query to execute" },
  305. },
  306. required: ["query"],
  307. })
  308. expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({ query: "select 1" }))).toBe(true)
  309. expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({}))).toBe(false)
  310. const agents = yield* Agent.Service
  311. const promptTools = yield* registry.tools({
  312. providerID: ProviderV2.ID.kirincode,
  313. modelID: ModelV2.ID.make("test"),
  314. agent: yield* agents.defaultInfo(),
  315. })
  316. const promptTool = promptTools.find((tool) => tool.id === "sql")
  317. if (!promptTool) throw new Error("custom sql tool was not returned for prompts")
  318. expect(ToolJsonSchema.fromTool(promptTool)).toMatchObject({
  319. properties: {
  320. query: { type: "string", description: "SQL query to execute" },
  321. },
  322. required: ["query"],
  323. })
  324. }),
  325. )
  326. it.instance(
  327. "preserves Zod arg descriptions from older config-scoped plugin packages",
  328. () =>
  329. Effect.gen(function* () {
  330. const test = yield* TestInstance
  331. const kirincode = path.join(test.directory, ".kirincode")
  332. const customTools = path.join(opencode, "tools")
  333. const plugin = path.join(opencode, "node_modules", "@kirincode-ai", "plugin")
  334. yield* Effect.promise(() => fs.mkdir(path.join(plugin, "dist"), { recursive: true }))
  335. yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true }))
  336. yield* Effect.promise(() =>
  337. fs.cp(path.dirname(fileURLToPath(import.meta.resolve("zod"))), path.join(opencode, "node_modules", "zod"), {
  338. dereference: true,
  339. recursive: true,
  340. }),
  341. )
  342. yield* Effect.promise(() =>
  343. Bun.write(
  344. path.join(plugin, "package.json"),
  345. JSON.stringify({ name: "@kirincode-ai/plugin", type: "module", exports: { ".": "./dist/index.js" } }),
  346. ),
  347. )
  348. yield* Effect.promise(() =>
  349. Bun.write(
  350. path.join(plugin, "dist", "index.js"),
  351. [
  352. "import { z } from 'zod'",
  353. "export function tool(input) {",
  354. " return input",
  355. "}",
  356. "tool.schema = z",
  357. "",
  358. ].join("\n"),
  359. ),
  360. )
  361. yield* Effect.promise(() =>
  362. Bun.write(
  363. path.join(customTools, "addition.ts"),
  364. [
  365. 'import { tool } from "@kirincode-ai/plugin"',
  366. "export default tool({",
  367. " description: 'Use this tool to add two numbers and return their sum.',",
  368. " args: {",
  369. " left: tool.schema.number().describe('The first number to add'),",
  370. " right: tool.schema.number().describe('The second number to add'),",
  371. " },",
  372. " execute: async (args) => `${args.left} + ${args.right} = ${args.left + args.right}`,",
  373. "})",
  374. "",
  375. ].join("\n"),
  376. ),
  377. )
  378. const registry = yield* ToolRegistry.Service
  379. const loaded = (yield* registry.all()).find((tool) => tool.id === "addition")
  380. if (!loaded) throw new Error("custom addition tool was not loaded")
  381. expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({
  382. properties: {
  383. left: { type: "number", description: "The first number to add" },
  384. right: { type: "number", description: "The second number to add" },
  385. },
  386. })
  387. }),
  388. 20_000,
  389. )
  390. it.instance("preserves attachments from structured custom tool results", () =>
  391. Effect.gen(function* () {
  392. const test = yield* TestInstance
  393. const customTools = path.join(test.directory, ".kirincode", "tools")
  394. const pluginTool = pathToFileURL(path.resolve(import.meta.dir, "../../../plugin/src/tool.ts")).href
  395. yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true }))
  396. yield* Effect.promise(() =>
  397. Bun.write(
  398. path.join(customTools, "image.ts"),
  399. [
  400. `import { tool } from ${JSON.stringify(pluginTool)}`,
  401. "export default tool({",
  402. " description: 'image tool',",
  403. " args: {},",
  404. " execute: async () => ({",
  405. " output: 'here is an image',",
  406. " attachments: [{ type: 'file', mime: 'image/png', filename: 'picture.png', url: 'data:image/png;base64,AAAA' }],",
  407. " }),",
  408. "})",
  409. "",
  410. ].join("\n"),
  411. ),
  412. )
  413. const registry = yield* ToolRegistry.Service
  414. const loaded = (yield* registry.all()).find((tool) => tool.id === "image")
  415. if (!loaded) throw new Error("custom image tool was not loaded")
  416. const agents = yield* Agent.Service
  417. const result = yield* loaded.execute({}, {
  418. sessionID: SessionID.make("ses_test"),
  419. messageID: MessageID.make("msg_test"),
  420. agent: (yield* agents.defaultInfo()).name,
  421. abort: new AbortController().signal,
  422. messages: [],
  423. metadata: () => Effect.void,
  424. ask: () => Effect.void,
  425. } satisfies Tool.Context)
  426. expect(result.output).toBe("here is an image")
  427. expect(result.attachments).toEqual([
  428. { type: "file", mime: "image/png", filename: "picture.png", url: "data:image/png;base64,AAAA" },
  429. ])
  430. }),
  431. )
  432. it.instance("loads legacy JSON-schema-shaped custom tools with wire schema", () =>
  433. Effect.gen(function* () {
  434. const test = yield* TestInstance
  435. const tools = path.join(test.directory, ".kirincode", "tools")
  436. yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
  437. yield* Effect.promise(() =>
  438. Bun.write(
  439. path.join(tools, "legacy.ts"),
  440. [
  441. "export default {",
  442. " description: 'legacy schema tool',",
  443. " args: { text: { type: 'string', description: 'Text to render' } },",
  444. " execute: async ({ text }) => text,",
  445. "}",
  446. "",
  447. ].join("\n"),
  448. ),
  449. )
  450. const registry = yield* ToolRegistry.Service
  451. const loaded = (yield* registry.all()).find((tool) => tool.id === "legacy")
  452. if (!loaded) throw new Error("legacy custom tool was not loaded")
  453. expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({
  454. type: "object",
  455. properties: {
  456. text: { type: "string", description: "Text to render" },
  457. },
  458. required: ["text"],
  459. })
  460. }),
  461. )
  462. it.instance("loads tools with external dependencies without crashing", () =>
  463. Effect.gen(function* () {
  464. const test = yield* TestInstance
  465. const kirincode = path.join(test.directory, ".kirincode")
  466. const tools = path.join(opencode, "tools")
  467. yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
  468. yield* Effect.promise(() =>
  469. Bun.write(
  470. path.join(opencode, "package.json"),
  471. JSON.stringify({
  472. name: "custom-tools",
  473. dependencies: {
  474. "@kirincode-ai/plugin": "^0.0.0",
  475. cowsay: "^1.6.0",
  476. },
  477. }),
  478. ),
  479. )
  480. yield* Effect.promise(() =>
  481. Bun.write(
  482. path.join(opencode, "package-lock.json"),
  483. JSON.stringify({
  484. name: "custom-tools",
  485. lockfileVersion: 3,
  486. packages: {
  487. "": {
  488. dependencies: {
  489. "@kirincode-ai/plugin": "^0.0.0",
  490. cowsay: "^1.6.0",
  491. },
  492. },
  493. },
  494. }),
  495. ),
  496. )
  497. const cowsay = path.join(opencode, "node_modules", "cowsay")
  498. yield* Effect.promise(() => fs.mkdir(cowsay, { recursive: true }))
  499. yield* Effect.promise(() =>
  500. Bun.write(
  501. path.join(cowsay, "package.json"),
  502. JSON.stringify({
  503. name: "cowsay",
  504. type: "module",
  505. exports: "./index.js",
  506. }),
  507. ),
  508. )
  509. yield* Effect.promise(() =>
  510. Bun.write(
  511. path.join(cowsay, "index.js"),
  512. ["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"),
  513. ),
  514. )
  515. yield* Effect.promise(() =>
  516. Bun.write(
  517. path.join(tools, "cowsay.ts"),
  518. [
  519. "import { say } from 'cowsay'",
  520. "export default {",
  521. " description: 'tool that imports cowsay at top level',",
  522. " args: { text: { type: 'string' } },",
  523. " execute: async ({ text }: { text: string }) => {",
  524. " return say({ text })",
  525. " },",
  526. "}",
  527. "",
  528. ].join("\n"),
  529. ),
  530. )
  531. const registry = yield* ToolRegistry.Service
  532. const ids = yield* registry.ids()
  533. expect(ids).toContain("cowsay")
  534. }),
  535. )
  536. })