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

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