tool-paths.test.ts 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. import { describe, expect, test } from "bun:test"
  2. import { Effect, Schema } from "effect"
  3. import { CodeMode, Tool } from "../src/index.js"
  4. const echo = (description: string, result: string) =>
  5. Tool.make({
  6. description,
  7. input: Schema.Struct({}),
  8. output: Schema.String,
  9. execute: () => Effect.succeed(result),
  10. })
  11. const value = async (runtime: CodeMode.Runtime, code: string) => {
  12. const result = await Effect.runPromise(runtime.execute(code))
  13. if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
  14. return result.value
  15. }
  16. const failure = async (runtime: CodeMode.Runtime, code: string) => {
  17. const result = await Effect.runPromise(runtime.execute(code))
  18. if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`)
  19. return result.error
  20. }
  21. describe("dotted tool names", () => {
  22. const runtime = CodeMode.make({ tools: { api: { "issues.list": echo("List issues", "listed") } } })
  23. test("a dotted name becomes nested namespaces in the catalog", () => {
  24. const catalog = runtime.catalog()
  25. expect(catalog).toHaveLength(1)
  26. expect(catalog[0]?.path).toBe("api.issues.list")
  27. expect(catalog[0]?.signature).toStartWith("tools.api.issues.list(input:")
  28. })
  29. test("the advertised dotted path is executable", async () => {
  30. expect(await value(runtime, `return await tools.api.issues.list({})`)).toBe("listed")
  31. })
  32. test("bracket access with a dotted segment spells the same canonical path", async () => {
  33. expect(await value(runtime, `return await tools.api["issues.list"]({})`)).toBe("listed")
  34. expect(await value(runtime, `return await tools["api.issues"].list({})`)).toBe("listed")
  35. })
  36. test("intermediate segments enumerate like ordinary namespaces", async () => {
  37. expect(await value(runtime, `return [Object.keys(tools.api), Object.keys(tools.api.issues)]`)).toEqual([
  38. ["issues"],
  39. ["list"],
  40. ])
  41. expect(await value(runtime, `return Object.keys(tools["api.issues"])`)).toEqual(["list"])
  42. })
  43. test("a top-level dotted name nests from the root", async () => {
  44. const flat = CodeMode.make({ tools: { "issues.list": echo("List issues", "flat") } })
  45. expect(flat.catalog()[0]?.path).toBe("issues.list")
  46. expect(await value(flat, `return await tools.issues.list({})`)).toBe("flat")
  47. })
  48. test("search scopes to a nested namespace subtree", async () => {
  49. const nested = CodeMode.make({
  50. tools: {
  51. slack: {
  52. admin: echo("Admin", "admin"),
  53. "admin.invite": echo("Invite", "invite"),
  54. "admin.users.list": echo("List users", "users"),
  55. "administrator.list": echo("List administrators", "administrators"),
  56. read: echo("Read Slack", "read"),
  57. },
  58. },
  59. })
  60. const result = await value(nested, `return search({ query: "", namespace: "slack.admin" })`)
  61. expect((result as { items: Array<{ path: string }> }).items.map((item) => item.path)).toEqual([
  62. "tools.slack.admin",
  63. "tools.slack.admin.invite",
  64. "tools.slack.admin.users.list",
  65. ])
  66. })
  67. })
  68. describe("callable namespaces", () => {
  69. const runtime = CodeMode.make({
  70. tools: { issues: echo("All issues", "all"), "issues.list": echo("List issues", "list") },
  71. })
  72. test("a path can hold a tool and child tools at once", async () => {
  73. expect(await value(runtime, `return await tools.issues({})`)).toBe("all")
  74. expect(await value(runtime, `return await tools.issues.list({})`)).toBe("list")
  75. expect(runtime.catalog().map((tool) => tool.path)).toEqual(["issues", "issues.list"])
  76. })
  77. test("a callable namespace enumerates its children", async () => {
  78. expect(await value(runtime, `return Object.keys(tools.issues)`)).toEqual(["list"])
  79. })
  80. test("search returns executable paths for both", async () => {
  81. const result = await value(runtime, `return search({ query: "", namespace: "issues" })`)
  82. expect((result as { items: Array<{ path: string }> }).items.map((item) => item.path)).toEqual([
  83. "tools.issues",
  84. "tools.issues.list",
  85. ])
  86. const exact = await value(runtime, `return search({ query: "tools.issues.list" })`)
  87. expect((exact as { items: Array<{ path: string }> }).items.map((item) => item.path)).toEqual(["tools.issues.list"])
  88. })
  89. test("an unknown child under a callable tool is an UnknownTool error", async () => {
  90. const diagnostic = await failure(runtime, `return await tools.issues.missing({})`)
  91. expect(diagnostic.kind).toBe("UnknownTool")
  92. expect(diagnostic.message).toContain("Unknown tool 'issues.missing'")
  93. expect(diagnostic.suggestions).toEqual([
  94. "The tool may have been removed or renamed. Use search to find available tools.",
  95. ])
  96. })
  97. test("a namespace without its own tool stays non-callable", async () => {
  98. const nested = CodeMode.make({ tools: { "issues.list": echo("List issues", "list") } })
  99. const diagnostic = await failure(nested, `return await tools.issues({})`)
  100. expect(diagnostic.kind).toBe("UnknownTool")
  101. expect(diagnostic.message).toContain("Tool 'issues' is not callable")
  102. })
  103. })
  104. describe("tool input diagnostics", () => {
  105. const runtime = CodeMode.make({
  106. tools: {
  107. "notes.echo": Tool.make({
  108. description: "Echo text",
  109. input: Schema.Struct({ text: Schema.String }),
  110. output: Schema.String,
  111. execute: ({ text }) => Effect.succeed(text),
  112. }),
  113. },
  114. })
  115. test("a schema mismatch suggests searching for the current signature", async () => {
  116. const diagnostic = await failure(runtime, `return await tools.notes.echo({ message: "hello" })`)
  117. expect(diagnostic.kind).toBe("InvalidToolInput")
  118. expect(diagnostic.suggestions).toEqual(["The signature may have changed. Use search to get the current signature."])
  119. })
  120. test("a wrong argument count keeps the existing error without a stale-signature hint", async () => {
  121. const diagnostic = await failure(runtime, `return await tools.notes.echo()`)
  122. expect(diagnostic.kind).toBe("InvalidToolInput")
  123. expect(diagnostic.suggestions).toBeUndefined()
  124. })
  125. })
  126. describe("blocked member names on tool paths", () => {
  127. const runtime = CodeMode.make({
  128. tools: {
  129. prototype: echo("Prototype tool", "proto"),
  130. "issues.constructor": echo("Constructor tool", "ctor"),
  131. nested: { ["__proto__"]: echo("Proto tool", "dunder") },
  132. },
  133. })
  134. test("tools may use blocked member names because path segments never touch real properties", async () => {
  135. expect(runtime.catalog().map((tool) => tool.path)).toEqual(["issues.constructor", "nested.__proto__", "prototype"])
  136. expect(await value(runtime, `return await tools.prototype({})`)).toBe("proto")
  137. expect(await value(runtime, `return await tools.issues.constructor({})`)).toBe("ctor")
  138. expect(await value(runtime, `return await tools["issues.constructor"]({})`)).toBe("ctor")
  139. expect(await value(runtime, `return await tools.nested.__proto__({})`)).toBe("dunder")
  140. expect(await value(runtime, `return Object.keys(tools.issues)`)).toEqual(["constructor"])
  141. })
  142. test("a literal __proto__ key cannot poison a namespace into a fake tool", async () => {
  143. const poisoned = CodeMode.make({
  144. tools: { ns: { __proto__: echo("Hidden", "hidden"), real: echo("Real tool", "real") } },
  145. })
  146. expect(poisoned.catalog().map((tool) => tool.path)).toEqual(["ns.real"])
  147. expect(await value(poisoned, `return await tools.ns.real({})`)).toBe("real")
  148. })
  149. test("blocked member access on data values stays blocked", async () => {
  150. const diagnostic = await failure(runtime, `const x = {}; return x.constructor`)
  151. expect(diagnostic.message).toContain("constructor")
  152. expect(Object.keys(Object.prototype)).toEqual([])
  153. })
  154. })
  155. describe("empty segments", () => {
  156. test("tool names with empty segments are rejected at make", () => {
  157. for (const name of ["", "a..b", "trail.", ".lead"]) {
  158. expect(() => CodeMode.make({ tools: { [name]: echo("Bad", "bad") } })).toThrow("empty segment")
  159. }
  160. })
  161. })
  162. describe("canonical path collisions", () => {
  163. test("the last tool supplied for a canonical path wins", async () => {
  164. const runtime = CodeMode.make({
  165. tools: { "issues.list": echo("First", "first"), issues: { list: echo("Second", "second") } },
  166. })
  167. expect(await value(runtime, `return await tools.issues.list({})`)).toBe("second")
  168. expect(runtime.catalog()).toHaveLength(1)
  169. expect(runtime.catalog()[0]?.description).toBe("Second")
  170. })
  171. test("overriding one path keeps sibling tools from both shapes", async () => {
  172. const runtime = CodeMode.make({
  173. tools: {
  174. "issues.list": echo("First list", "first"),
  175. issues: { list: echo("Second list", "second"), get: echo("Get issue", "got") },
  176. "issues.close": echo("Close issue", "closed"),
  177. },
  178. })
  179. expect(runtime.catalog().map((tool) => tool.path)).toEqual(["issues.close", "issues.get", "issues.list"])
  180. expect(await value(runtime, `return await tools.issues.list({})`)).toBe("second")
  181. expect(await value(runtime, `return await tools.issues.get({})`)).toBe("got")
  182. expect(await value(runtime, `return await tools.issues.close({})`)).toBe("closed")
  183. })
  184. })