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

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