session-runner-tool-registry.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400
  1. import { describe, expect } from "bun:test"
  2. import { Tool } from "@opencode-ai/core/tool/tool"
  3. import { AgentV2 } from "@opencode-ai/core/agent"
  4. import type { PermissionV2 } from "@opencode-ai/core/permission"
  5. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  6. import { SessionV2 } from "@opencode-ai/core/session"
  7. import { SessionMessage } from "@opencode-ai/core/session/message"
  8. import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
  9. import { ToolRegistry } from "@opencode-ai/core/tool/registry"
  10. import { executeTool, settleTool, toolDefinitions } from "./lib/tool"
  11. import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
  12. import { testEffect } from "./lib/effect"
  13. const bounds: ToolOutputStore.BoundInput[] = []
  14. const retentionFailure = new ToolOutputStore.StorageError({ operation: "write", cause: new Error("disk full") })
  15. const outputStore = Layer.mock(ToolOutputStore.Service, {
  16. bound: (input) => {
  17. if (input.toolCallID === "call-retention-failure") return Effect.fail(retentionFailure)
  18. return Effect.sync(() => bounds.push(input)).pipe(
  19. Effect.as(
  20. input.toolCallID === "call-bounded"
  21. ? {
  22. output: { structured: {}, content: [{ type: "text" as const, text: "bounded reference" }] },
  23. outputPaths: ["/managed/generic"],
  24. }
  25. : { output: input.output, outputPaths: [] },
  26. ),
  27. )
  28. },
  29. })
  30. const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [[ToolOutputStore.node, outputStore]])
  31. const it = testEffect(registryLayer)
  32. const identity = {
  33. agent: AgentV2.ID.make("build"),
  34. assistantMessageID: SessionMessage.ID.make("msg_registry"),
  35. }
  36. const sessionID = SessionV2.ID.make("ses_registry")
  37. const call = (name: string, id = `call-${name}`): ToolRegistry.ExecuteInput => ({
  38. sessionID,
  39. ...identity,
  40. call: { type: "tool-call", id, name, input: { text: name } },
  41. })
  42. const make = (permission?: string) => {
  43. const tool = Tool.make({
  44. description: "Echo text",
  45. input: Schema.Struct({ text: Schema.String }),
  46. output: Schema.Struct({ text: Schema.String }),
  47. execute: ({ text }) => Effect.succeed({ text }),
  48. toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
  49. })
  50. return permission ? Tool.withPermission(tool, permission) : tool
  51. }
  52. const constant = (text: string) =>
  53. Tool.make({
  54. description: "Return text",
  55. input: Schema.Struct({ text: Schema.String }),
  56. output: Schema.Struct({ text: Schema.String }),
  57. execute: () => Effect.succeed({ text }),
  58. toModelOutput: ({ output }) => [{ type: "text" as const, text: output.text }],
  59. })
  60. describe("ToolRegistry", () => {
  61. it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
  62. Effect.gen(function* () {
  63. const service = yield* ToolRegistry.Service
  64. yield* service.register({
  65. question: make(),
  66. bash: make(),
  67. edit: make("edit"),
  68. write: make("edit"),
  69. }, { codemode: false })
  70. const names = (permissions: PermissionV2.Ruleset) =>
  71. toolDefinitions(service, permissions).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
  72. expect(yield* names([{ action: "question", resource: "*", effect: "deny" }])).toEqual(["bash", "edit", "write"])
  73. expect(
  74. yield* names([
  75. { action: "*", resource: "*", effect: "deny" },
  76. { action: "question", resource: "private", effect: "allow" },
  77. ]),
  78. ).toEqual(["question"])
  79. expect(
  80. yield* names([
  81. { action: "question", resource: "private", effect: "allow" },
  82. { action: "*", resource: "*", effect: "deny" },
  83. ]),
  84. ).toEqual([])
  85. expect(yield* names([{ action: "edit", resource: "*", effect: "deny" }])).toEqual(["question", "bash"])
  86. }),
  87. )
  88. it.effect("keeps permission decoration isolated between registrations", () =>
  89. Effect.gen(function* () {
  90. const service = yield* ToolRegistry.Service
  91. const shared = make()
  92. yield* service.register({ first: shared }, { codemode: false })
  93. yield* service.register({ second: Tool.withPermission(shared, "edit") }, { codemode: false })
  94. Tool.withPermission(shared, "question")
  95. expect(
  96. (yield* toolDefinitions(service, [{ action: "edit", resource: "*", effect: "deny" }])).map(
  97. (definition) => definition.name,
  98. ),
  99. ).toEqual(["first"])
  100. }),
  101. )
  102. it.effect("reuses model definitions across requests", () =>
  103. Effect.gen(function* () {
  104. const service = yield* ToolRegistry.Service
  105. yield* service.register({ echo: make() }, { codemode: false })
  106. const first = yield* toolDefinitions(service)
  107. const second = yield* toolDefinitions(service)
  108. expect(second[0]).toBe(first[0])
  109. }),
  110. )
  111. it.effect("removes a scoped registration", () =>
  112. Effect.gen(function* () {
  113. const service = yield* ToolRegistry.Service
  114. const scope = yield* Scope.make()
  115. yield* service.register({ echo: make() }, { codemode: false }).pipe(Scope.provide(scope))
  116. expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo"])
  117. yield* Scope.close(scope, Exit.void)
  118. expect(yield* toolDefinitions(service)).toEqual([])
  119. }),
  120. )
  121. it.effect("preserves an interrupted registration until its scope closes", () =>
  122. Effect.gen(function* () {
  123. const service = yield* ToolRegistry.Service
  124. const scope = yield* Scope.make()
  125. const registered = yield* Deferred.make<void>()
  126. const fiber = yield* service
  127. .register({ echo: make() }, { codemode: false })
  128. .pipe(
  129. Effect.andThen(Deferred.succeed(registered, undefined)),
  130. Effect.andThen(Effect.never),
  131. Scope.provide(scope),
  132. Effect.forkChild,
  133. )
  134. yield* Deferred.await(registered)
  135. yield* Fiber.interrupt(fiber)
  136. expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo"])
  137. yield* Scope.close(scope, Exit.void)
  138. expect(yield* toolDefinitions(service)).toEqual([])
  139. }),
  140. )
  141. it.effect("returns model errors without swallowing interruption or defects", () =>
  142. Effect.gen(function* () {
  143. const service = yield* ToolRegistry.Service
  144. yield* service.register({
  145. failed: Tool.make({
  146. description: "Failed",
  147. input: Schema.Struct({}),
  148. output: Schema.Struct({ ok: Schema.Boolean }),
  149. execute: () => Effect.fail(new Tool.Failure({ message: "Denied" })),
  150. }),
  151. }, { codemode: false })
  152. expect(
  153. yield* executeTool(service, {
  154. sessionID,
  155. ...identity,
  156. call: { type: "tool-call", id: "failed", name: "failed", input: {} },
  157. }),
  158. ).toEqual({ type: "error", value: "Denied" })
  159. expect(
  160. yield* executeTool(service, {
  161. sessionID,
  162. ...identity,
  163. call: { type: "tool-call", id: "missing", name: "missing", input: {} },
  164. }),
  165. ).toEqual({ type: "error", value: "Unknown tool: missing" })
  166. yield* service.register({
  167. defect: Tool.make({
  168. description: "Defect",
  169. input: Schema.Struct({}),
  170. output: Schema.Struct({}),
  171. execute: () => Effect.die("unexpected executor defect"),
  172. }),
  173. }, { codemode: false })
  174. expect(
  175. yield* service.materialize().pipe(
  176. Effect.flatMap((materialized) =>
  177. materialized.settle({
  178. sessionID,
  179. ...identity,
  180. call: { type: "tool-call", id: "defect", name: "defect", input: {} },
  181. }),
  182. ),
  183. Effect.catchDefect(Effect.succeed),
  184. ),
  185. ).toBe("unexpected executor defect")
  186. }),
  187. )
  188. it.effect("propagates retention failures through settlement", () =>
  189. Effect.gen(function* () {
  190. const service = yield* ToolRegistry.Service
  191. yield* service.register({ echo: make() }, { codemode: false })
  192. const materialized = yield* service.materialize()
  193. const exit = yield* materialized.settle(call("echo", "call-retention-failure")).pipe(Effect.exit)
  194. expect(Exit.isFailure(exit)).toBe(true)
  195. if (Exit.isFailure(exit)) expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toBe(retentionFailure)
  196. expect(retentionFailure.message).toBe("Failed to write tool output: disk full")
  197. }),
  198. )
  199. it.effect("exposes settlement only through materialization", () =>
  200. Effect.gen(function* () {
  201. const service = yield* ToolRegistry.Service
  202. expect("definitions" in service).toBe(false)
  203. expect("execute" in service).toBe(false)
  204. expect("settle" in service).toBe(false)
  205. expect(typeof service.materialize).toBe("function")
  206. }),
  207. )
  208. it.effect("passes complete invocation identity to the canonical handler", () =>
  209. Effect.gen(function* () {
  210. const service = yield* ToolRegistry.Service
  211. const contexts: Tool.Context[] = []
  212. yield* service.register({
  213. context: Tool.make({
  214. description: "Context",
  215. input: Schema.Struct({}),
  216. output: Schema.Struct({ ok: Schema.Boolean }),
  217. execute: (_, context) => Effect.sync(() => contexts.push(context)).pipe(Effect.as({ ok: true })),
  218. }),
  219. }, { codemode: false })
  220. yield* executeTool(service, {
  221. sessionID,
  222. ...identity,
  223. call: { type: "tool-call", id: "call-context", name: "context", input: {} },
  224. })
  225. expect(contexts).toEqual([{ sessionID, ...identity, toolCallID: "call-context" }])
  226. }),
  227. )
  228. it.effect("encodes output and applies generic settlement bounding", () =>
  229. Effect.gen(function* () {
  230. bounds.length = 0
  231. const service = yield* ToolRegistry.Service
  232. yield* service.register({ bounded: make() }, { codemode: false })
  233. expect(
  234. yield* settleTool(service, {
  235. sessionID,
  236. ...identity,
  237. call: { type: "tool-call", id: "call-bounded", name: "bounded", input: { text: "complete" } },
  238. }),
  239. ).toEqual({
  240. result: { type: "text", value: "bounded reference" },
  241. output: { structured: {}, content: [{ type: "text", text: "bounded reference" }] },
  242. outputPaths: ["/managed/generic"],
  243. })
  244. expect(bounds).toHaveLength(1)
  245. }),
  246. )
  247. it.effect("enforces transformed codecs at execution and projection boundaries", () =>
  248. Effect.gen(function* () {
  249. const service = yield* ToolRegistry.Service
  250. const executed: string[] = []
  251. const Transformed = Schema.Boolean.pipe(
  252. Schema.decodeTo(Schema.String, {
  253. decode: SchemaGetter.transform((value) => (value ? "yes" : "no")),
  254. encode: SchemaGetter.transform((value) => value === "yes"),
  255. }),
  256. )
  257. yield* service.register({
  258. transformed: Tool.make({
  259. description: "Transform values",
  260. input: Schema.Struct({ value: Transformed }),
  261. output: Schema.Struct({ value: Transformed }),
  262. execute: ({ value }) => Effect.sync(() => executed.push(value)).pipe(Effect.as({ value })),
  263. toModelOutput: ({ output }) => [{ type: "text", text: String(output.value) }],
  264. }),
  265. }, { codemode: false })
  266. expect(
  267. yield* executeTool(service, {
  268. sessionID,
  269. ...identity,
  270. call: { type: "tool-call", id: "transformed", name: "transformed", input: { value: true } },
  271. }),
  272. ).toEqual({ type: "text", value: "true" })
  273. expect(executed).toEqual(["yes"])
  274. expect(
  275. yield* executeTool(service, {
  276. sessionID,
  277. ...identity,
  278. call: { type: "tool-call", id: "invalid-input", name: "transformed", input: { value: "yes" } },
  279. }),
  280. ).toMatchObject({ type: "error", value: expect.stringContaining("Invalid tool input") })
  281. expect(executed).toEqual(["yes"])
  282. yield* service.register({
  283. invalid_output: Tool.make({
  284. description: "Return invalid output",
  285. input: Schema.Struct({}),
  286. output: Schema.Struct({
  287. value: Schema.Boolean.pipe(
  288. Schema.decodeTo(Schema.String, {
  289. decode: SchemaGetter.transform((value) => String(value)),
  290. encode: SchemaGetter.transformOrFail((value) =>
  291. value === "valid"
  292. ? Effect.succeed(true)
  293. : Effect.fail(new SchemaIssue.InvalidValue(Option.some(value), { message: "invalid output" })),
  294. ),
  295. }),
  296. ),
  297. }),
  298. execute: () => Effect.succeed({ value: "invalid" }),
  299. }),
  300. }, { codemode: false })
  301. expect(
  302. yield* executeTool(service, {
  303. sessionID,
  304. ...identity,
  305. call: { type: "tool-call", id: "invalid-output", name: "invalid_output", input: {} },
  306. }),
  307. ).toMatchObject({ type: "error", value: expect.stringContaining("invalid value for its output schema") })
  308. }),
  309. )
  310. it.effect("executes the tool advertised in a model request", () =>
  311. Effect.gen(function* () {
  312. const service = yield* ToolRegistry.Service
  313. const scope = yield* Scope.make()
  314. yield* service.register({ echo: constant("advertised") }, { codemode: false }).pipe(Scope.provide(scope))
  315. const request = yield* service.materialize()
  316. yield* Scope.close(scope, Exit.void)
  317. yield* service.register({ echo: constant("replacement") }, { codemode: false })
  318. expect((yield* request.settle(call("echo"))).result).toEqual({ type: "text", value: "advertised" })
  319. expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "replacement" })
  320. }),
  321. )
  322. it.effect("reveals the previous registration after an overlay closes", () =>
  323. Effect.gen(function* () {
  324. const service = yield* ToolRegistry.Service
  325. yield* service.register({ echo: constant("base") }, { codemode: false })
  326. const overlay = yield* Scope.make()
  327. yield* service.register({ echo: constant("overlay") }, { codemode: false }).pipe(Scope.provide(overlay))
  328. expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "overlay" })
  329. yield* Scope.close(overlay, Exit.void)
  330. expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "base" })
  331. }),
  332. )
  333. it.effect("executes codemode tools advertised in a model request", () =>
  334. Effect.gen(function* () {
  335. const service = yield* ToolRegistry.Service
  336. const executed: string[] = []
  337. const scope = yield* Scope.make()
  338. yield* service
  339. .register({
  340. echo: Tool.make({
  341. description: "Echo text",
  342. input: Schema.Struct({ text: Schema.String }),
  343. output: Schema.Struct({ text: Schema.String }),
  344. execute: ({ text }) => Effect.sync(() => executed.push(`old:${text}`)).pipe(Effect.as({ text })),
  345. }),
  346. })
  347. .pipe(Scope.provide(scope))
  348. const materialized = yield* service.materialize()
  349. yield* Scope.close(scope, Exit.void)
  350. yield* service.register({
  351. echo: Tool.make({
  352. description: "Echo text",
  353. input: Schema.Struct({ text: Schema.String }),
  354. output: Schema.Struct({ text: Schema.String }),
  355. execute: ({ text }) => Effect.sync(() => executed.push(`new:${text}`)).pipe(Effect.as({ text })),
  356. }),
  357. })
  358. const settlement = yield* materialized.settle({
  359. ...call("execute"),
  360. call: {
  361. type: "tool-call",
  362. id: "call-execute",
  363. name: "execute",
  364. input: { code: 'return await tools.echo({ text: "request" })' },
  365. },
  366. })
  367. expect(settlement.result).toMatchObject({ type: "text" })
  368. expect(executed).toEqual(["old:request"])
  369. }),
  370. )
  371. })