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

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