1
0

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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584
  1. import { describe, expect } from "bun:test"
  2. import { Agent } from "@opencode-ai/core/agent"
  3. import type { Permission } from "@opencode-ai/core/permission"
  4. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  5. import { Image } from "@opencode-ai/core/image"
  6. import { Session } from "@opencode-ai/core/session"
  7. import { SessionMessage } from "@opencode-ai/core/session/message"
  8. import { Tool } from "@opencode-ai/core/tool"
  9. import type { Info } from "@opencode-ai/schema/tool"
  10. import { executeTool, 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 imageStore = Layer.mock(Image.Service, {
  14. normalize: (resource, content) => {
  15. if (resource === "corrupt.png") return Effect.fail(new Image.DecodeError({ resource }))
  16. if (resource === "too-large.png")
  17. return Effect.fail(
  18. new Image.SizeError({
  19. resource,
  20. width: 9_000,
  21. height: 9_000,
  22. bytes: content.content.length,
  23. maxWidth: 2_000,
  24. maxHeight: 2_000,
  25. maxBytes: 5,
  26. }),
  27. )
  28. return Effect.succeed({ ...content, content: "bm9ybWFsaXplZA==", mime: "image/jpeg" })
  29. },
  30. })
  31. const registryLayer = AppNodeBuilder.build(Tool.node, [[Image.node, imageStore]])
  32. const it = testEffect(registryLayer)
  33. const identity = {
  34. agent: Agent.ID.make("build"),
  35. messageID: SessionMessage.ID.make("msg_registry"),
  36. }
  37. const sessionID = Session.ID.make("ses_registry")
  38. const call = (name: string, id = `call-${name}`): Parameters<Tool.Snapshot["execute"]>[0] => ({
  39. sessionID,
  40. ...identity,
  41. call: { type: "tool-call", id, name, input: { text: name } },
  42. })
  43. const make = (): Info =>
  44. ({
  45. name: "echo",
  46. description: "Echo text",
  47. input: Schema.Struct({ text: Schema.String }),
  48. output: Schema.Struct({ text: Schema.String }),
  49. execute: ({ text }) => Effect.succeed({ output: { text }, content: text }),
  50. })
  51. const constant = (text: string): Info =>
  52. ({
  53. name: "constant",
  54. description: "Return text",
  55. input: Schema.Struct({ text: Schema.String }),
  56. output: Schema.Struct({ text: Schema.String }),
  57. execute: () => Effect.succeed({ output: { text }, content: text }),
  58. })
  59. const transform = (
  60. service: Tool.Interface,
  61. tools: Readonly<Record<string, Info>>,
  62. options?: Tool.Options,
  63. ) =>
  64. service.transform((draft) =>
  65. Object.entries(tools).forEach(([name, tool]) =>
  66. draft.add({ ...tool, name, options: options ?? tool.options }),
  67. ),
  68. )
  69. describe("Tool", () => {
  70. it.effect("rejects invalid dotted namespaces", () =>
  71. Effect.gen(function* () {
  72. const service = yield* Tool.Service
  73. const error = yield* transform(service, { echo: make() }, { namespace: "slack..admin" }).pipe(Effect.flip)
  74. expect(error).toBeInstanceOf(Tool.RegistrationError)
  75. expect(error.message).toBe('Invalid tool namespace: "slack..admin"')
  76. expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
  77. }),
  78. )
  79. it.effect("rejects invalid and colliding normalized names", () =>
  80. Effect.gen(function* () {
  81. const service = yield* Tool.Service
  82. const invalid = yield* transform(service, { "123": make() }, { codemode: false }).pipe(Effect.flip)
  83. expect(invalid.message).toBe("Invalid tool name: 123")
  84. const collision = yield* transform(service, { "echo.tool": make(), echo_tool: make() }, { codemode: false })
  85. .pipe(Effect.flip)
  86. expect(collision.message).toBe("Duplicate normalized tool name: echo_tool")
  87. expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
  88. }),
  89. )
  90. it.effect("validates a registration batch before installing any tools", () =>
  91. Effect.gen(function* () {
  92. const service = yield* Tool.Service
  93. const error = yield* service
  94. .transform((draft) => {
  95. draft.add({ ...make(), name: "first", options: { codemode: false } })
  96. draft.add({ ...make(), name: "second", options: { namespace: "invalid..namespace", codemode: false } })
  97. })
  98. .pipe(Effect.flip)
  99. expect(error).toBeInstanceOf(Tool.RegistrationError)
  100. expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
  101. }),
  102. )
  103. it.effect("canonicalizes effective definitions and keeps Code Mode last", () =>
  104. Effect.gen(function* () {
  105. const service = yield* Tool.Service
  106. const tool = make()
  107. const capture = (tools: ReadonlyArray<Info>) =>
  108. Effect.scoped(
  109. Effect.gen(function* () {
  110. yield* service.transform((draft) => tools.forEach(draft.add))
  111. return (yield* service.snapshot()).definitions
  112. }),
  113. )
  114. const first = yield* capture([
  115. { ...tool, name: "zeta", options: { codemode: false } },
  116. { ...tool, name: "alpha", options: { codemode: false } },
  117. { ...tool, name: "beta", options: { namespace: "alpha", codemode: false } },
  118. { ...tool, name: "echo" },
  119. ])
  120. const second = yield* capture([
  121. { ...tool, name: "echo" },
  122. { ...tool, name: "beta", options: { namespace: "alpha", codemode: false } },
  123. { ...tool, name: "alpha", options: { codemode: false } },
  124. { ...tool, name: "zeta", options: { codemode: false } },
  125. ])
  126. expect(first).toEqual(second)
  127. expect(first.map((definition) => definition.name)).toEqual(["alpha", "alpha_beta", "zeta", "execute"])
  128. }),
  129. )
  130. it.effect("snapshots external tools with missing input schemas", () =>
  131. Effect.gen(function* () {
  132. const service = yield* Tool.Service
  133. yield* service.transform((draft) =>
  134. draft.add({
  135. ...make(),
  136. input: undefined,
  137. } as unknown as Info),
  138. )
  139. const snapshot = yield* service.snapshot()
  140. expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["execute"])
  141. expect(snapshot.codeModeCatalog?.[0]?.signature).toContain("tools.echo")
  142. }),
  143. )
  144. it.effect("keeps execute available without Code Mode tools unless explicitly denied", () =>
  145. Effect.gen(function* () {
  146. const service = yield* Tool.Service
  147. const available = yield* service.snapshot()
  148. expect(available.definitions.map((tool) => tool.name)).toEqual(["execute"])
  149. expect(available.codeModeCatalog).toEqual([])
  150. const denied = yield* service.snapshot([{ action: "execute", resource: "*", effect: "deny" }])
  151. expect(denied.definitions).toEqual([])
  152. expect(denied.codeModeCatalog).toBeUndefined()
  153. }),
  154. )
  155. it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
  156. Effect.gen(function* () {
  157. const service = yield* Tool.Service
  158. yield* transform(service, { question: make(), bash: make() }, { codemode: false })
  159. yield* transform(service, { edit: make(), write: make() }, { codemode: false, permission: "edit" })
  160. const names = (permissions: Permission.Ruleset) =>
  161. toolDefinitions(service, permissions).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
  162. expect(yield* names([{ action: "question", resource: "*", effect: "deny" }])).toEqual([
  163. "bash",
  164. "edit",
  165. "write",
  166. "execute",
  167. ])
  168. expect(
  169. yield* names([
  170. { action: "*", resource: "*", effect: "deny" },
  171. { action: "question", resource: "private", effect: "allow" },
  172. ]),
  173. ).toEqual(["question"])
  174. expect(
  175. yield* names([
  176. { action: "question", resource: "private", effect: "allow" },
  177. { action: "*", resource: "*", effect: "deny" },
  178. ]),
  179. ).toEqual([])
  180. expect(yield* names([{ action: "edit", resource: "*", effect: "deny" }])).toEqual([
  181. "bash",
  182. "question",
  183. "execute",
  184. ])
  185. }),
  186. )
  187. it.effect("keeps permission options isolated between registrations", () =>
  188. Effect.gen(function* () {
  189. const service = yield* Tool.Service
  190. const shared = make()
  191. yield* transform(service, { first: shared }, { codemode: false })
  192. yield* transform(service, { second: shared }, { codemode: false, permission: "edit" })
  193. expect(
  194. (yield* toolDefinitions(service, [{ action: "edit", resource: "*", effect: "deny" }])).map((tool) => tool.name),
  195. ).toEqual(["first", "execute"])
  196. }),
  197. )
  198. it.effect("removes a scoped registration", () =>
  199. Effect.gen(function* () {
  200. const service = yield* Tool.Service
  201. const scope = yield* Scope.make()
  202. yield* transform(service, { echo: make() }, { codemode: false }).pipe(Scope.provide(scope))
  203. expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo", "execute"])
  204. yield* Scope.close(scope, Exit.void)
  205. expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["execute"])
  206. }),
  207. )
  208. it.effect("preserves an interrupted registration until its scope closes", () =>
  209. Effect.gen(function* () {
  210. const service = yield* Tool.Service
  211. const scope = yield* Scope.make()
  212. const registered = yield* Deferred.make<void>()
  213. const fiber = yield* transform(service, { echo: make() }, { codemode: false })
  214. .pipe(
  215. Effect.andThen(Deferred.succeed(registered, undefined)),
  216. Effect.andThen(Effect.never),
  217. Scope.provide(scope),
  218. Effect.forkChild,
  219. )
  220. yield* Deferred.await(registered)
  221. yield* Fiber.interrupt(fiber)
  222. expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["echo", "execute"])
  223. yield* Scope.close(scope, Exit.void)
  224. expect((yield* toolDefinitions(service)).map((tool) => tool.name)).toEqual(["execute"])
  225. }),
  226. )
  227. it.effect("returns model errors without swallowing interruption or defects", () =>
  228. Effect.gen(function* () {
  229. const service = yield* Tool.Service
  230. yield* transform(service,
  231. {
  232. failed: ({
  233. name: "failed",
  234. description: "Failed",
  235. input: Schema.Struct({}),
  236. output: Schema.Struct({ ok: Schema.Boolean }),
  237. execute: () => Effect.fail(new Tool.Error({ message: "Denied" })),
  238. }),
  239. },
  240. { codemode: false },
  241. )
  242. expect(
  243. yield* executeTool(service, {
  244. sessionID,
  245. ...identity,
  246. call: { type: "tool-call", id: "failed", name: "failed", input: {} },
  247. }),
  248. ).toEqual({ status: "error", error: { type: "tool.execution", message: "Denied" } })
  249. expect(
  250. yield* executeTool(service, {
  251. sessionID,
  252. ...identity,
  253. call: { type: "tool-call", id: "missing", name: "missing", input: {} },
  254. }),
  255. ).toEqual({ status: "error", error: { type: "tool.execution", message: "Unknown tool: missing" } })
  256. yield* transform(service,
  257. {
  258. defect: ({
  259. name: "defect",
  260. description: "Defect",
  261. input: Schema.Struct({}),
  262. output: Schema.Struct({}),
  263. execute: () => Effect.die("unexpected executor defect"),
  264. }),
  265. },
  266. { codemode: false },
  267. )
  268. expect(
  269. yield* service.snapshot().pipe(
  270. Effect.flatMap((toolSet) =>
  271. toolSet.execute({
  272. sessionID,
  273. ...identity,
  274. call: { type: "tool-call", id: "defect", name: "defect", input: {} },
  275. }),
  276. ),
  277. Effect.catchDefect(Effect.succeed),
  278. ),
  279. ).toBe("unexpected executor defect")
  280. }),
  281. )
  282. it.effect("exposes execution only through a snapshot", () =>
  283. Effect.gen(function* () {
  284. const service = yield* Tool.Service
  285. expect("definitions" in service).toBe(false)
  286. expect("execute" in service).toBe(false)
  287. expect("settle" in service).toBe(false)
  288. expect(typeof service.snapshot).toBe("function")
  289. }),
  290. )
  291. it.effect("passes complete call identity to tool execution", () =>
  292. Effect.gen(function* () {
  293. const service = yield* Tool.Service
  294. const contexts: Tool.Context[] = []
  295. yield* transform(service,
  296. {
  297. context: ({
  298. name: "context",
  299. description: "Context",
  300. input: Schema.Struct({}),
  301. output: Schema.Struct({ ok: Schema.Boolean }),
  302. execute: (_, context) =>
  303. Effect.sync(() => contexts.push(context)).pipe(Effect.as({ output: { ok: true } })),
  304. }),
  305. },
  306. { codemode: false },
  307. )
  308. yield* executeTool(service, {
  309. sessionID,
  310. ...identity,
  311. call: { type: "tool-call", id: "call-context", name: "context", input: {} },
  312. })
  313. expect(contexts).toEqual([
  314. { sessionID, ...identity, id: Tool.CallID.make("call-context"), progress: expect.any(Function) },
  315. ])
  316. }),
  317. )
  318. it.effect("normalizes image tool output at execution and drops unresizable images", () =>
  319. Effect.gen(function* () {
  320. const service = yield* Tool.Service
  321. yield* transform(service,
  322. {
  323. snapshot: ({
  324. name: "snapshot",
  325. description: "Return images",
  326. input: Schema.Struct({ text: Schema.String }),
  327. output: Schema.Struct({ text: Schema.String }),
  328. execute: ({ text }) =>
  329. Effect.succeed({
  330. output: { text },
  331. content: [
  332. { type: "file", uri: "data:image/png;base64,aW1hZ2U=", mime: "image/png", name: "frame.png" },
  333. {
  334. type: "file",
  335. uri: "data:image/png;base64,aW1hZ2U=",
  336. mime: "image/png",
  337. name: "too-large.png",
  338. },
  339. { type: "file", uri: "data:image/png;base64,aW1hZ2U=", mime: "image/png", name: "corrupt.png" },
  340. { type: "text", text },
  341. ],
  342. }),
  343. }),
  344. },
  345. { codemode: false },
  346. )
  347. const execution = yield* executeTool(service, call("snapshot"))
  348. expect(execution.content).toEqual([
  349. { type: "file", uri: "data:image/jpeg;base64,bm9ybWFsaXplZA==", mime: "image/jpeg", name: "frame.png" },
  350. { type: "text", text: "snapshot" },
  351. { type: "text", text: "[1 image omitted: could not be decoded.]" },
  352. { type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" },
  353. ])
  354. }),
  355. )
  356. it.effect("publishes progress metadata unchanged", () =>
  357. Effect.gen(function* () {
  358. const service = yield* Tool.Service
  359. yield* transform(service,
  360. {
  361. progressive: ({
  362. name: "progressive",
  363. description: "Emit image progress",
  364. input: Schema.Struct({ text: Schema.String }),
  365. output: Schema.Struct({ text: Schema.String }),
  366. execute: ({ text }, context) =>
  367. context.progress({ stage: "capture" }).pipe(Effect.as({ output: { text } })),
  368. }),
  369. },
  370. { codemode: false },
  371. )
  372. const updates: Tool.Metadata[] = []
  373. yield* executeTool(service, {
  374. ...call("progressive"),
  375. progress: (update) =>
  376. Effect.sync(() => {
  377. updates.push(update)
  378. }),
  379. })
  380. expect(updates).toEqual([{ stage: "capture" }])
  381. }),
  382. )
  383. it.effect("enforces transformed codecs at execution and projection boundaries", () =>
  384. Effect.gen(function* () {
  385. const service = yield* Tool.Service
  386. const executed: string[] = []
  387. const Transformed = Schema.Boolean.pipe(
  388. Schema.decodeTo(Schema.String, {
  389. decode: SchemaGetter.transform((value) => (value ? "yes" : "no")),
  390. encode: SchemaGetter.transform((value) => value === "yes"),
  391. }),
  392. )
  393. yield* transform(service,
  394. {
  395. transformed: ({
  396. name: "transformed",
  397. description: "Transform values",
  398. input: Schema.Struct({ value: Transformed }),
  399. output: Schema.Struct({ value: Transformed }),
  400. execute: ({ value }) =>
  401. Effect.sync(() => executed.push(value)).pipe(Effect.as({ output: { value }, content: String(value) })),
  402. }),
  403. },
  404. { codemode: false },
  405. )
  406. // Canonical content observes the decoded domain value; Code Mode observes the encoded value.
  407. expect(
  408. yield* executeTool(service, {
  409. sessionID,
  410. ...identity,
  411. call: { type: "tool-call", id: "transformed", name: "transformed", input: { value: true } },
  412. }),
  413. ).toEqual({
  414. status: "completed",
  415. output: { value: true },
  416. content: [{ type: "text", text: "yes" }],
  417. })
  418. expect(executed).toEqual(["yes"])
  419. expect(
  420. yield* executeTool(service, {
  421. sessionID,
  422. ...identity,
  423. call: { type: "tool-call", id: "invalid-input", name: "transformed", input: { value: "yes" } },
  424. }),
  425. ).toMatchObject({
  426. status: "error",
  427. error: { type: "tool.execution", message: expect.stringContaining("Invalid tool input") },
  428. })
  429. expect(executed).toEqual(["yes"])
  430. yield* transform(service,
  431. {
  432. invalid_output: ({
  433. name: "invalid_output",
  434. description: "Return invalid output",
  435. input: Schema.Struct({}),
  436. output: Schema.Struct({
  437. value: Schema.Boolean.pipe(
  438. Schema.decodeTo(Schema.String, {
  439. decode: SchemaGetter.transform((value) => String(value)),
  440. encode: SchemaGetter.transformOrFail((value) =>
  441. value === "valid"
  442. ? Effect.succeed(true)
  443. : Effect.fail(new SchemaIssue.InvalidValue(Option.some(value), { message: "invalid output" })),
  444. ),
  445. }),
  446. ),
  447. }),
  448. execute: () => Effect.succeed({ output: { value: "invalid" } }),
  449. }),
  450. },
  451. { codemode: false },
  452. )
  453. expect(
  454. yield* executeTool(service, {
  455. sessionID,
  456. ...identity,
  457. call: { type: "tool-call", id: "invalid-output", name: "invalid_output", input: {} },
  458. }),
  459. ).toMatchObject({
  460. status: "error",
  461. error: { type: "tool.execution", message: expect.stringContaining("invalid value for its output schema") },
  462. })
  463. }),
  464. )
  465. it.effect("executes the tool advertised in a model request", () =>
  466. Effect.gen(function* () {
  467. const service = yield* Tool.Service
  468. const scope = yield* Scope.make()
  469. yield* transform(service, { echo: constant("advertised") }, { codemode: false }).pipe(Scope.provide(scope))
  470. const request = yield* service.snapshot()
  471. yield* Scope.close(scope, Exit.void)
  472. yield* transform(service, { echo: constant("replacement") }, { codemode: false })
  473. expect((yield* request.execute(call("echo"))).content).toEqual([{ type: "text", text: "advertised" }])
  474. expect((yield* executeTool(service, call("echo"))).content).toEqual([{ type: "text", text: "replacement" }])
  475. }),
  476. )
  477. it.effect("reveals the previous registration after an overlay closes", () =>
  478. Effect.gen(function* () {
  479. const service = yield* Tool.Service
  480. yield* transform(service, { echo: constant("base") }, { codemode: false })
  481. const overlay = yield* Scope.make()
  482. yield* transform(service, { echo: constant("overlay") }, { codemode: false }).pipe(Scope.provide(overlay))
  483. expect((yield* executeTool(service, call("echo"))).content).toEqual([{ type: "text", text: "overlay" }])
  484. yield* Scope.close(overlay, Exit.void)
  485. expect((yield* executeTool(service, call("echo"))).content).toEqual([{ type: "text", text: "base" }])
  486. }),
  487. )
  488. it.effect("executes and reports progress for codemode tools advertised in a model request", () =>
  489. Effect.gen(function* () {
  490. const service = yield* Tool.Service
  491. const executed: string[] = []
  492. const scope = yield* Scope.make()
  493. yield* transform(service, {
  494. echo: ({
  495. name: "echo",
  496. description: "Echo text",
  497. input: Schema.Struct({ text: Schema.String }),
  498. output: Schema.Struct({ text: Schema.String }),
  499. execute: ({ text }, context) =>
  500. Effect.sync(() => executed.push(`old:${text}`)).pipe(
  501. Effect.andThen(context.progress({ stage: "old" })),
  502. Effect.as({ output: { text } }),
  503. ),
  504. }),
  505. })
  506. .pipe(Scope.provide(scope))
  507. const toolSet = yield* service.snapshot()
  508. const execute = toolSet.definitions.find((tool) => tool.name === "execute")
  509. expect(toolSet.codeModeCatalog?.[0]?.signature).toContain("tools.echo")
  510. expect(execute?.description).toContain("confined Code Mode runtime")
  511. expect(execute?.description).not.toContain("Echo text")
  512. yield* Scope.close(scope, Exit.void)
  513. yield* transform(service, {
  514. echo: ({
  515. name: "echo",
  516. description: "Echo text",
  517. input: Schema.Struct({ text: Schema.String }),
  518. output: Schema.Struct({ text: Schema.String }),
  519. execute: ({ text }) => Effect.sync(() => executed.push(`new:${text}`)).pipe(Effect.as({ output: { text } })),
  520. }),
  521. })
  522. const progress: Tool.Metadata[] = []
  523. const execution = yield* toolSet.execute({
  524. ...call("execute"),
  525. call: {
  526. type: "tool-call",
  527. id: "call-execute",
  528. name: "execute",
  529. input: { code: 'return await tools.echo({ text: "request" })' },
  530. },
  531. progress: (update) => Effect.sync(() => progress.push(update)),
  532. })
  533. expect(execution).toMatchObject({ content: [{ type: "text" }] })
  534. expect(executed).toEqual(["old:request"])
  535. expect(progress).toEqual([
  536. { toolCalls: [{ tool: "echo", status: "running", input: { text: "request" } }] },
  537. { stage: "old" },
  538. { toolCalls: [{ tool: "echo", status: "completed", input: { text: "request" } }] },
  539. ])
  540. }),
  541. )
  542. })