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

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