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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  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 { Image } from "@opencode-ai/core/image"
  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.callID === "call-retention-failure") return Effect.fail(retentionFailure)
  19. return Effect.sync(() => bounds.push(input)).pipe(
  20. Effect.as(
  21. input.callID === "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 imageStore = Layer.mock(Image.Service, {
  32. normalize: (resource, content) => {
  33. if (resource === "corrupt.png") return Effect.fail(new Image.DecodeError({ resource }))
  34. if (resource === "too-large.png")
  35. return Effect.fail(
  36. new Image.SizeError({
  37. resource,
  38. width: 9_000,
  39. height: 9_000,
  40. bytes: content.content.length,
  41. maxWidth: 2_000,
  42. maxHeight: 2_000,
  43. maxBytes: 5,
  44. }),
  45. )
  46. return Effect.succeed({ ...content, content: "bm9ybWFsaXplZA==", mime: "image/jpeg" })
  47. },
  48. })
  49. const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [
  50. [ToolOutputStore.node, outputStore],
  51. [Image.node, imageStore],
  52. ])
  53. const it = testEffect(registryLayer)
  54. const identity = {
  55. agent: AgentV2.ID.make("build"),
  56. messageID: SessionMessage.ID.make("msg_registry"),
  57. }
  58. const sessionID = SessionV2.ID.make("ses_registry")
  59. const call = (name: string, id = `call-${name}`): ToolRegistry.ExecuteInput => ({
  60. sessionID,
  61. ...identity,
  62. call: { type: "tool-call", id, name, input: { text: name } },
  63. })
  64. const make = (permission?: string) => {
  65. const tool = Tool.make({
  66. description: "Echo text",
  67. input: Schema.Struct({ text: Schema.String }),
  68. output: Schema.Struct({ text: Schema.String }),
  69. execute: ({ text }) => Effect.succeed({ text }),
  70. toModelOutput: ({ output }) => [{ type: "text", text: output.text }],
  71. })
  72. return permission ? Tool.withPermission(tool, permission) : tool
  73. }
  74. const constant = (text: string) =>
  75. Tool.make({
  76. description: "Return text",
  77. input: Schema.Struct({ text: Schema.String }),
  78. output: Schema.Struct({ text: Schema.String }),
  79. execute: () => Effect.succeed({ text }),
  80. toModelOutput: ({ output }) => [{ type: "text" as const, text: output.text }],
  81. })
  82. describe("ToolRegistry", () => {
  83. it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
  84. Effect.gen(function* () {
  85. const service = yield* ToolRegistry.Service
  86. yield* service.register({
  87. question: make(),
  88. bash: make(),
  89. edit: make("edit"),
  90. write: make("edit"),
  91. }, { codemode: false })
  92. const names = (permissions: PermissionV2.Ruleset) =>
  93. toolDefinitions(service, permissions).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
  94. expect(yield* names([{ action: "question", resource: "*", effect: "deny" }])).toEqual(["bash", "edit", "write"])
  95. expect(
  96. yield* names([
  97. { action: "*", resource: "*", effect: "deny" },
  98. { action: "question", resource: "private", effect: "allow" },
  99. ]),
  100. ).toEqual(["question"])
  101. expect(
  102. yield* names([
  103. { action: "question", resource: "private", effect: "allow" },
  104. { action: "*", resource: "*", effect: "deny" },
  105. ]),
  106. ).toEqual([])
  107. expect(yield* names([{ action: "edit", resource: "*", effect: "deny" }])).toEqual(["question", "bash"])
  108. }),
  109. )
  110. it.effect("keeps permission decoration isolated between registrations", () =>
  111. Effect.gen(function* () {
  112. const service = yield* ToolRegistry.Service
  113. const shared = make()
  114. yield* service.register({ first: shared }, { codemode: false })
  115. yield* service.register({ second: Tool.withPermission(shared, "edit") }, { codemode: false })
  116. Tool.withPermission(shared, "question")
  117. expect(
  118. (yield* toolDefinitions(service, [{ action: "edit", resource: "*", effect: "deny" }])).map(
  119. (definition) => definition.name,
  120. ),
  121. ).toEqual(["first"])
  122. }),
  123. )
  124. it.effect("removes a scoped registration", () =>
  125. Effect.gen(function* () {
  126. const service = yield* ToolRegistry.Service
  127. const scope = yield* Scope.make()
  128. yield* service.register({ echo: make() }, { codemode: false }).pipe(Scope.provide(scope))
  129. expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo"])
  130. yield* Scope.close(scope, Exit.void)
  131. expect(yield* toolDefinitions(service)).toEqual([])
  132. }),
  133. )
  134. it.effect("preserves an interrupted registration until its scope closes", () =>
  135. Effect.gen(function* () {
  136. const service = yield* ToolRegistry.Service
  137. const scope = yield* Scope.make()
  138. const registered = yield* Deferred.make<void>()
  139. const fiber = yield* service
  140. .register({ echo: make() }, { codemode: false })
  141. .pipe(
  142. Effect.andThen(Deferred.succeed(registered, undefined)),
  143. Effect.andThen(Effect.never),
  144. Scope.provide(scope),
  145. Effect.forkChild,
  146. )
  147. yield* Deferred.await(registered)
  148. yield* Fiber.interrupt(fiber)
  149. expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo"])
  150. yield* Scope.close(scope, Exit.void)
  151. expect(yield* toolDefinitions(service)).toEqual([])
  152. }),
  153. )
  154. it.effect("returns model errors without swallowing interruption or defects", () =>
  155. Effect.gen(function* () {
  156. const service = yield* ToolRegistry.Service
  157. yield* service.register({
  158. failed: Tool.make({
  159. description: "Failed",
  160. input: Schema.Struct({}),
  161. output: Schema.Struct({ ok: Schema.Boolean }),
  162. execute: () => Effect.fail(new Tool.Failure({ message: "Denied" })),
  163. }),
  164. }, { codemode: false })
  165. expect(
  166. yield* executeTool(service, {
  167. sessionID,
  168. ...identity,
  169. call: { type: "tool-call", id: "failed", name: "failed", input: {} },
  170. }),
  171. ).toEqual({ type: "error", value: "Denied" })
  172. expect(
  173. yield* executeTool(service, {
  174. sessionID,
  175. ...identity,
  176. call: { type: "tool-call", id: "missing", name: "missing", input: {} },
  177. }),
  178. ).toEqual({ type: "error", value: "Unknown tool: missing" })
  179. yield* service.register({
  180. defect: Tool.make({
  181. description: "Defect",
  182. input: Schema.Struct({}),
  183. output: Schema.Struct({}),
  184. execute: () => Effect.die("unexpected executor defect"),
  185. }),
  186. }, { codemode: false })
  187. expect(
  188. yield* service.materialize().pipe(
  189. Effect.flatMap((materialized) =>
  190. materialized.settle({
  191. sessionID,
  192. ...identity,
  193. call: { type: "tool-call", id: "defect", name: "defect", input: {} },
  194. }),
  195. ),
  196. Effect.catchDefect(Effect.succeed),
  197. ),
  198. ).toBe("unexpected executor defect")
  199. }),
  200. )
  201. it.effect("propagates retention failures through settlement", () =>
  202. Effect.gen(function* () {
  203. const service = yield* ToolRegistry.Service
  204. yield* service.register({ echo: make() }, { codemode: false })
  205. const materialized = yield* service.materialize()
  206. const exit = yield* materialized.settle(call("echo", "call-retention-failure")).pipe(Effect.exit)
  207. expect(Exit.isFailure(exit)).toBe(true)
  208. if (Exit.isFailure(exit)) expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toBe(retentionFailure)
  209. expect(retentionFailure.message).toBe("Failed to write tool output: disk full")
  210. }),
  211. )
  212. it.effect("exposes settlement only through materialization", () =>
  213. Effect.gen(function* () {
  214. const service = yield* ToolRegistry.Service
  215. expect("definitions" in service).toBe(false)
  216. expect("execute" in service).toBe(false)
  217. expect("settle" in service).toBe(false)
  218. expect(typeof service.materialize).toBe("function")
  219. }),
  220. )
  221. it.effect("passes complete invocation identity to the canonical handler", () =>
  222. Effect.gen(function* () {
  223. const service = yield* ToolRegistry.Service
  224. const contexts: Tool.Context[] = []
  225. yield* service.register({
  226. context: Tool.make({
  227. description: "Context",
  228. input: Schema.Struct({}),
  229. output: Schema.Struct({ ok: Schema.Boolean }),
  230. execute: (_, context) => Effect.sync(() => contexts.push(context)).pipe(Effect.as({ ok: true })),
  231. }),
  232. }, { codemode: false })
  233. yield* executeTool(service, {
  234. sessionID,
  235. ...identity,
  236. call: { type: "tool-call", id: "call-context", name: "context", input: {} },
  237. })
  238. expect(contexts).toEqual([
  239. { sessionID, ...identity, callID: "call-context", progress: expect.any(Function) },
  240. ])
  241. }),
  242. )
  243. it.effect("encodes output and applies generic settlement bounding", () =>
  244. Effect.gen(function* () {
  245. bounds.length = 0
  246. const service = yield* ToolRegistry.Service
  247. yield* service.register({ bounded: make() }, { codemode: false })
  248. expect(
  249. yield* settleTool(service, {
  250. sessionID,
  251. ...identity,
  252. call: { type: "tool-call", id: "call-bounded", name: "bounded", input: { text: "complete" } },
  253. }),
  254. ).toEqual({
  255. result: { type: "text", value: "bounded reference" },
  256. output: { structured: {}, content: [{ type: "text", text: "bounded reference" }] },
  257. outputPaths: ["/managed/generic"],
  258. })
  259. expect(bounds).toHaveLength(1)
  260. }),
  261. )
  262. it.effect("normalizes image tool output at settlement and drops unresizable images", () =>
  263. Effect.gen(function* () {
  264. const service = yield* ToolRegistry.Service
  265. yield* service.register({
  266. snapshot: Tool.make({
  267. description: "Return images",
  268. input: Schema.Struct({ text: Schema.String }),
  269. output: Schema.Struct({ text: Schema.String }),
  270. execute: ({ text }) => Effect.succeed({ text }),
  271. toModelOutput: ({ output }) => [
  272. { type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" },
  273. { type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" },
  274. { type: "file", data: "aW1hZ2U=", mime: "image/png", name: "corrupt.png" },
  275. { type: "text", text: output.text },
  276. ],
  277. }),
  278. }, { codemode: false })
  279. const settlement = yield* settleTool(service, call("snapshot"))
  280. expect(settlement.output?.content).toEqual([
  281. { type: "file", uri: "data:image/jpeg;base64,bm9ybWFsaXplZA==", mime: "image/jpeg", name: "frame.png" },
  282. { type: "text", text: "snapshot" },
  283. { type: "text", text: "[1 image omitted: could not be decoded.]" },
  284. { type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" },
  285. ])
  286. }),
  287. )
  288. it.effect("normalizes image progress content before it is published", () =>
  289. Effect.gen(function* () {
  290. const service = yield* ToolRegistry.Service
  291. yield* service.register({
  292. progressive: Tool.make({
  293. description: "Emit image progress",
  294. input: Schema.Struct({ text: Schema.String }),
  295. output: Schema.Struct({ text: Schema.String }),
  296. execute: ({ text }, context) =>
  297. context
  298. .progress({
  299. structured: { stage: "capture" },
  300. content: [
  301. { type: "file", data: "aW1hZ2U=", mime: "image/png", name: "frame.png" },
  302. { type: "file", data: "aW1hZ2U=", mime: "image/png", name: "too-large.png" },
  303. ],
  304. })
  305. .pipe(Effect.as({ text })),
  306. }),
  307. }, { codemode: false })
  308. const updates: ToolRegistry.Progress[] = []
  309. yield* settleTool(service, {
  310. ...call("progressive"),
  311. progress: (update) =>
  312. Effect.sync(() => {
  313. updates.push(update)
  314. }),
  315. })
  316. expect(updates).toEqual([
  317. {
  318. structured: { stage: "capture" },
  319. content: [
  320. { type: "file", uri: "data:image/jpeg;base64,bm9ybWFsaXplZA==", mime: "image/jpeg", name: "frame.png" },
  321. { type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" },
  322. ],
  323. },
  324. ])
  325. }),
  326. )
  327. it.effect("enforces transformed codecs at execution and projection boundaries", () =>
  328. Effect.gen(function* () {
  329. const service = yield* ToolRegistry.Service
  330. const executed: string[] = []
  331. const Transformed = Schema.Boolean.pipe(
  332. Schema.decodeTo(Schema.String, {
  333. decode: SchemaGetter.transform((value) => (value ? "yes" : "no")),
  334. encode: SchemaGetter.transform((value) => value === "yes"),
  335. }),
  336. )
  337. yield* service.register({
  338. transformed: Tool.make({
  339. description: "Transform values",
  340. input: Schema.Struct({ value: Transformed }),
  341. output: Schema.Struct({ value: Transformed }),
  342. execute: ({ value }) => Effect.sync(() => executed.push(value)).pipe(Effect.as({ value })),
  343. toModelOutput: ({ output }) => [{ type: "text", text: String(output.value) }],
  344. }),
  345. }, { codemode: false })
  346. expect(
  347. yield* executeTool(service, {
  348. sessionID,
  349. ...identity,
  350. call: { type: "tool-call", id: "transformed", name: "transformed", input: { value: true } },
  351. }),
  352. ).toEqual({ type: "text", value: "true" })
  353. expect(executed).toEqual(["yes"])
  354. expect(
  355. yield* executeTool(service, {
  356. sessionID,
  357. ...identity,
  358. call: { type: "tool-call", id: "invalid-input", name: "transformed", input: { value: "yes" } },
  359. }),
  360. ).toMatchObject({ type: "error", value: expect.stringContaining("Invalid tool input") })
  361. expect(executed).toEqual(["yes"])
  362. yield* service.register({
  363. invalid_output: Tool.make({
  364. description: "Return invalid output",
  365. input: Schema.Struct({}),
  366. output: Schema.Struct({
  367. value: Schema.Boolean.pipe(
  368. Schema.decodeTo(Schema.String, {
  369. decode: SchemaGetter.transform((value) => String(value)),
  370. encode: SchemaGetter.transformOrFail((value) =>
  371. value === "valid"
  372. ? Effect.succeed(true)
  373. : Effect.fail(new SchemaIssue.InvalidValue(Option.some(value), { message: "invalid output" })),
  374. ),
  375. }),
  376. ),
  377. }),
  378. execute: () => Effect.succeed({ value: "invalid" }),
  379. }),
  380. }, { codemode: false })
  381. expect(
  382. yield* executeTool(service, {
  383. sessionID,
  384. ...identity,
  385. call: { type: "tool-call", id: "invalid-output", name: "invalid_output", input: {} },
  386. }),
  387. ).toMatchObject({ type: "error", value: expect.stringContaining("invalid value for its output schema") })
  388. }),
  389. )
  390. it.effect("executes the tool advertised in a model request", () =>
  391. Effect.gen(function* () {
  392. const service = yield* ToolRegistry.Service
  393. const scope = yield* Scope.make()
  394. yield* service.register({ echo: constant("advertised") }, { codemode: false }).pipe(Scope.provide(scope))
  395. const request = yield* service.materialize()
  396. yield* Scope.close(scope, Exit.void)
  397. yield* service.register({ echo: constant("replacement") }, { codemode: false })
  398. expect((yield* request.settle(call("echo"))).result).toEqual({ type: "text", value: "advertised" })
  399. expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "replacement" })
  400. }),
  401. )
  402. it.effect("reveals the previous registration after an overlay closes", () =>
  403. Effect.gen(function* () {
  404. const service = yield* ToolRegistry.Service
  405. yield* service.register({ echo: constant("base") }, { codemode: false })
  406. const overlay = yield* Scope.make()
  407. yield* service.register({ echo: constant("overlay") }, { codemode: false }).pipe(Scope.provide(overlay))
  408. expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "overlay" })
  409. yield* Scope.close(overlay, Exit.void)
  410. expect(yield* executeTool(service, call("echo"))).toEqual({ type: "text", value: "base" })
  411. }),
  412. )
  413. it.effect("executes codemode tools advertised in a model request", () =>
  414. Effect.gen(function* () {
  415. const service = yield* ToolRegistry.Service
  416. const executed: string[] = []
  417. const scope = yield* Scope.make()
  418. yield* service
  419. .register({
  420. echo: Tool.make({
  421. description: "Echo text",
  422. input: Schema.Struct({ text: Schema.String }),
  423. output: Schema.Struct({ text: Schema.String }),
  424. execute: ({ text }) => Effect.sync(() => executed.push(`old:${text}`)).pipe(Effect.as({ text })),
  425. }),
  426. })
  427. .pipe(Scope.provide(scope))
  428. const materialized = yield* service.materialize()
  429. yield* Scope.close(scope, Exit.void)
  430. yield* service.register({
  431. echo: Tool.make({
  432. description: "Echo text",
  433. input: Schema.Struct({ text: Schema.String }),
  434. output: Schema.Struct({ text: Schema.String }),
  435. execute: ({ text }) => Effect.sync(() => executed.push(`new:${text}`)).pipe(Effect.as({ text })),
  436. }),
  437. })
  438. const settlement = yield* materialized.settle({
  439. ...call("execute"),
  440. call: {
  441. type: "tool-call",
  442. id: "call-execute",
  443. name: "execute",
  444. input: { code: 'return await tools.echo({ text: "request" })' },
  445. },
  446. })
  447. expect(settlement.result).toMatchObject({ type: "text" })
  448. expect(executed).toEqual(["old:request"])
  449. }),
  450. )
  451. })