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

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