tool-runtime.test.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589
  1. import { describe, expect } from "bun:test"
  2. import { Effect, Schema, Stream } from "effect"
  3. import { GenerationOptions, LLM, LLMEvent, LLMRequest, LLMResponse, ToolChoice } from "../src"
  4. import { Auth, LLMClient } from "../src/route"
  5. import * as AnthropicMessages from "../src/protocols/anthropic-messages"
  6. import * as OpenAIChat from "../src/protocols/openai-chat"
  7. import { tool, ToolFailure, type ToolExecuteContext } from "../src/tool"
  8. import { ToolRuntime } from "../src/tool-runtime"
  9. import { it } from "./lib/effect"
  10. import * as TestToolRuntime from "./lib/tool-runtime"
  11. import { dynamicResponse, scriptedResponses } from "./lib/http"
  12. import { deltaChunk, finishChunk, toolCallChunk } from "./lib/openai-chunks"
  13. import { sseEvents } from "./lib/sse"
  14. const model = OpenAIChat.route
  15. .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
  16. .model({ id: "gpt-4o-mini" })
  17. const Json = Schema.fromJsonString(Schema.Unknown)
  18. const decodeJson = Schema.decodeUnknownSync(Json)
  19. const baseRequest = LLM.request({
  20. id: "req_1",
  21. model,
  22. prompt: "Use the tool.",
  23. })
  24. const weatherFailureCause = new Error("weather lookup denied")
  25. const get_weather = tool({
  26. description: "Get current weather for a city.",
  27. parameters: Schema.Struct({ city: Schema.String }),
  28. success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }),
  29. execute: ({ city }) =>
  30. Effect.gen(function* () {
  31. if (city === "FAIL")
  32. return yield* new ToolFailure({ message: `Weather lookup failed for ${city}`, error: weatherFailureCause })
  33. return { temperature: 22, condition: "sunny" }
  34. }),
  35. })
  36. const schema_only_weather = tool({
  37. description: "Get current weather for a city.",
  38. parameters: Schema.Struct({ city: Schema.String }),
  39. success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }),
  40. })
  41. describe("LLMClient tools", () => {
  42. it.effect("uses the registered model route when adding runtime tools", () =>
  43. Effect.gen(function* () {
  44. const layer = scriptedResponses([
  45. sseEvents(deltaChunk({ role: "assistant", content: "Done." }), finishChunk("stop")),
  46. ])
  47. const events = Array.from(
  48. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
  49. Stream.runCollect,
  50. Effect.provide(layer),
  51. ),
  52. )
  53. expect(LLMResponse.text({ events })).toBe("Done.")
  54. }),
  55. )
  56. it.effect("sends tool-call history and request options on the follow-up request", () =>
  57. Effect.gen(function* () {
  58. const bodies: unknown[] = []
  59. const responses = [
  60. sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")),
  61. sseEvents(deltaChunk({ role: "assistant", content: "It's sunny in Paris." }), finishChunk("stop")),
  62. ]
  63. const layer = dynamicResponse((input) =>
  64. Effect.sync(() => {
  65. bodies.push(decodeJson(input.text))
  66. return input.respond(responses[bodies.length - 1] ?? responses[responses.length - 1], {
  67. headers: { "content-type": "text/event-stream" },
  68. })
  69. }),
  70. )
  71. yield* TestToolRuntime.runTools({
  72. request: LLMRequest.update(baseRequest, {
  73. generation: GenerationOptions.make({ maxTokens: 50 }),
  74. toolChoice: ToolChoice.make("auto"),
  75. }),
  76. tools: { get_weather },
  77. }).pipe(Stream.runCollect, Effect.provide(layer))
  78. const second = bodies[1]
  79. if (!second || typeof second !== "object") throw new Error("Expected second request body")
  80. const messages = Reflect.get(second, "messages")
  81. const tools = Reflect.get(second, "tools")
  82. expect(Reflect.get(second, "max_tokens")).toBe(50)
  83. expect(Reflect.get(second, "tool_choice")).toBe("auto")
  84. expect(tools).toHaveLength(1)
  85. expect(
  86. Array.isArray(messages)
  87. ? messages.map((message) =>
  88. message && typeof message === "object" ? Reflect.get(message, "role") : undefined,
  89. )
  90. : undefined,
  91. ).toEqual(["user", "assistant", "tool"])
  92. expect(Array.isArray(messages) ? messages[1] : undefined).toMatchObject({
  93. role: "assistant",
  94. content: null,
  95. tool_calls: [{ id: "call_1", type: "function", function: { name: "get_weather" } }],
  96. })
  97. expect(Array.isArray(messages) ? messages[2] : undefined).toMatchObject({
  98. role: "tool",
  99. tool_call_id: "call_1",
  100. content: '{"temperature":22,"condition":"sunny"}',
  101. })
  102. }),
  103. )
  104. it.effect("dispatches a tool call, appends results, and resumes streaming", () =>
  105. Effect.gen(function* () {
  106. const layer = scriptedResponses([
  107. sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")),
  108. sseEvents(deltaChunk({ role: "assistant", content: "It's sunny in Paris." }), finishChunk("stop")),
  109. ])
  110. const events = Array.from(
  111. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
  112. Stream.runCollect,
  113. Effect.provide(layer),
  114. ),
  115. )
  116. const result = events.find(LLMEvent.is.toolResult)
  117. expect(result).toMatchObject({
  118. type: "tool-result",
  119. id: "call_1",
  120. name: "get_weather",
  121. result: { type: "json", value: { temperature: 22, condition: "sunny" } },
  122. })
  123. expect(events.at(-1)?.type).toBe("finish")
  124. expect(LLMResponse.text({ events })).toBe("It's sunny in Paris.")
  125. }),
  126. )
  127. it.effect("preserves content tool results from dynamic tools", () =>
  128. Effect.gen(function* () {
  129. const screenshot = tool({
  130. description: "Capture a screenshot.",
  131. jsonSchema: { type: "object", properties: {} },
  132. execute: () =>
  133. Effect.succeed({
  134. type: "content" as const,
  135. value: [
  136. { type: "text" as const, text: "Screenshot captured." },
  137. { type: "media" as const, mediaType: "image/png", data: "AAAA" },
  138. ],
  139. }),
  140. })
  141. const events = Array.from(
  142. yield* LLMClient.stream({ request: baseRequest, tools: { screenshot } }).pipe(
  143. Stream.runCollect,
  144. Effect.provide(
  145. scriptedResponses([sseEvents(toolCallChunk("call_1", "screenshot", "{}"), finishChunk("tool_calls"))]),
  146. ),
  147. ),
  148. )
  149. expect(events.find(LLMEvent.is.toolResult)).toMatchObject({
  150. type: "tool-result",
  151. id: "call_1",
  152. name: "screenshot",
  153. result: {
  154. type: "content",
  155. value: [
  156. { type: "text", text: "Screenshot captured." },
  157. { type: "media", mediaType: "image/png", data: "AAAA" },
  158. ],
  159. },
  160. })
  161. }),
  162. )
  163. it.effect("executes tool calls for one step without looping by default", () =>
  164. Effect.gen(function* () {
  165. const layer = scriptedResponses([
  166. sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")),
  167. sseEvents(deltaChunk({ role: "assistant", content: "Should not run." }), finishChunk("stop")),
  168. ])
  169. const events = Array.from(
  170. yield* LLMClient.stream({ request: baseRequest, tools: { get_weather } }).pipe(
  171. Stream.runCollect,
  172. Effect.provide(layer),
  173. ),
  174. )
  175. expect(events.filter(LLMEvent.is.finish)).toHaveLength(1)
  176. expect(events.find(LLMEvent.is.toolResult)).toMatchObject({ type: "tool-result", id: "call_1" })
  177. }),
  178. )
  179. it.effect("passes tool call context to execute", () =>
  180. Effect.gen(function* () {
  181. let context: ToolExecuteContext | undefined
  182. const contextual = tool({
  183. description: "Capture tool context.",
  184. parameters: Schema.Struct({ value: Schema.String }),
  185. success: Schema.Struct({ ok: Schema.Boolean }),
  186. execute: (_params, ctx) =>
  187. Effect.sync(() => {
  188. context = ctx
  189. return { ok: true }
  190. }),
  191. })
  192. const events = Array.from(
  193. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { contextual } }).pipe(
  194. Stream.runCollect,
  195. Effect.provide(
  196. scriptedResponses([
  197. sseEvents(toolCallChunk("call_ctx", "contextual", '{"value":"x"}'), finishChunk("tool_calls")),
  198. ]),
  199. ),
  200. ),
  201. )
  202. expect(events.some(LLMEvent.is.toolResult)).toBe(true)
  203. expect(context).toEqual({ id: "call_ctx", name: "contextual" })
  204. }),
  205. )
  206. it.effect("can expose tool schemas without executing tool calls", () =>
  207. Effect.gen(function* () {
  208. const layer = scriptedResponses([
  209. sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")),
  210. ])
  211. const events = Array.from(
  212. yield* LLMClient.stream({
  213. request: baseRequest,
  214. tools: { get_weather: schema_only_weather },
  215. toolExecution: "none",
  216. }).pipe(Stream.runCollect, Effect.provide(layer)),
  217. )
  218. expect(events.find(LLMEvent.is.toolCall)).toMatchObject({ type: "tool-call", id: "call_1" })
  219. expect(events.find(LLMEvent.is.toolResult)).toBeUndefined()
  220. }),
  221. )
  222. it.effect("preserves provider metadata when folding streamed assistant content into follow-up history", () =>
  223. Effect.gen(function* () {
  224. const bodies: unknown[] = []
  225. const layer = dynamicResponse((input) =>
  226. Effect.sync(() => {
  227. bodies.push(decodeJson(input.text))
  228. return input.respond(
  229. bodies.length === 1
  230. ? sseEvents(
  231. { type: "message_start", message: { usage: { input_tokens: 5 } } },
  232. { type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "" } },
  233. { type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "thinking" } },
  234. { type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "sig_1" } },
  235. { type: "content_block_stop", index: 0 },
  236. {
  237. type: "content_block_start",
  238. index: 1,
  239. content_block: { type: "tool_use", id: "call_1", name: "get_weather" },
  240. },
  241. {
  242. type: "content_block_delta",
  243. index: 1,
  244. delta: { type: "input_json_delta", partial_json: '{"city":"Paris"}' },
  245. },
  246. { type: "content_block_stop", index: 1 },
  247. { type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 5 } },
  248. )
  249. : sseEvents(
  250. { type: "message_start", message: { usage: { input_tokens: 5 } } },
  251. { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
  252. { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Done." } },
  253. { type: "content_block_stop", index: 0 },
  254. { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
  255. ),
  256. { headers: { "content-type": "text/event-stream" } },
  257. )
  258. }),
  259. )
  260. yield* TestToolRuntime.runTools({
  261. request: LLM.updateRequest(baseRequest, {
  262. model: AnthropicMessages.route
  263. .with({ auth: Auth.header("x-api-key", "test") })
  264. .model({ id: "claude-sonnet-4-5" }),
  265. }),
  266. tools: { get_weather },
  267. }).pipe(Stream.runCollect, Effect.provide(layer))
  268. expect(bodies[1]).toMatchObject({
  269. messages: [
  270. { role: "user" },
  271. {
  272. role: "assistant",
  273. content: [
  274. { type: "thinking", thinking: "thinking", signature: "sig_1" },
  275. { type: "tool_use", id: "call_1", name: "get_weather", input: { city: "Paris" } },
  276. ],
  277. },
  278. { role: "user", content: [{ type: "tool_result", tool_use_id: "call_1" }] },
  279. ],
  280. })
  281. }),
  282. )
  283. it.effect("emits tool-error for unknown tools so the model can self-correct", () =>
  284. Effect.gen(function* () {
  285. const layer = scriptedResponses([
  286. sseEvents(toolCallChunk("call_1", "missing_tool", "{}"), finishChunk("tool_calls")),
  287. sseEvents(deltaChunk({ role: "assistant", content: "Sorry." }), finishChunk("stop")),
  288. ])
  289. const events = Array.from(
  290. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
  291. Stream.runCollect,
  292. Effect.provide(layer),
  293. ),
  294. )
  295. const toolError = events.find(LLMEvent.is.toolError)
  296. expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "missing_tool" })
  297. expect(toolError?.message).toContain("Unknown tool")
  298. expect(events.find(LLMEvent.is.toolResult)).toMatchObject({
  299. type: "tool-result",
  300. id: "call_1",
  301. name: "missing_tool",
  302. result: { type: "error", value: "Unknown tool: missing_tool" },
  303. })
  304. }),
  305. )
  306. it.effect("emits tool-error when the LLM input fails the parameters schema", () =>
  307. Effect.gen(function* () {
  308. const layer = scriptedResponses([
  309. sseEvents(toolCallChunk("call_1", "get_weather", '{"city":42}'), finishChunk("tool_calls")),
  310. sseEvents(deltaChunk({ role: "assistant", content: "Done." }), finishChunk("stop")),
  311. ])
  312. const events = Array.from(
  313. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
  314. Stream.runCollect,
  315. Effect.provide(layer),
  316. ),
  317. )
  318. const toolError = events.find(LLMEvent.is.toolError)
  319. expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "get_weather" })
  320. expect(toolError?.message).toContain("Invalid tool input")
  321. }),
  322. )
  323. it.effect("emits tool-error when the handler returns a ToolFailure", () =>
  324. Effect.gen(function* () {
  325. const layer = scriptedResponses([
  326. sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"FAIL"}'), finishChunk("tool_calls")),
  327. sseEvents(deltaChunk({ role: "assistant", content: "Sorry." }), finishChunk("stop")),
  328. ])
  329. const events = Array.from(
  330. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
  331. Stream.runCollect,
  332. Effect.provide(layer),
  333. ),
  334. )
  335. const toolError = events.find(LLMEvent.is.toolError)
  336. expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "get_weather" })
  337. expect(toolError?.message).toBe("Weather lookup failed for FAIL")
  338. expect(toolError?.error).toBe(weatherFailureCause)
  339. }),
  340. )
  341. it.effect("stops when the model finishes without requesting more tools", () =>
  342. Effect.gen(function* () {
  343. const layer = scriptedResponses([
  344. sseEvents(deltaChunk({ role: "assistant", content: "Done." }), finishChunk("stop")),
  345. ])
  346. const events = Array.from(
  347. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
  348. Stream.runCollect,
  349. Effect.provide(layer),
  350. ),
  351. )
  352. expect(events.map((event) => event.type)).toEqual([
  353. "step-start",
  354. "text-start",
  355. "text-delta",
  356. "text-end",
  357. "step-finish",
  358. "finish",
  359. ])
  360. expect(LLMResponse.text({ events })).toBe("Done.")
  361. }),
  362. )
  363. it.effect("respects maxSteps and stops the loop", () =>
  364. Effect.gen(function* () {
  365. // Every script entry asks for another tool call. With maxSteps: 2 the
  366. // runtime should run at most two model rounds and then exit even though
  367. // the model still wants to keep going.
  368. const toolCallStep = sseEvents(
  369. toolCallChunk("call_x", "get_weather", '{"city":"Paris"}'),
  370. finishChunk("tool_calls"),
  371. )
  372. const layer = scriptedResponses([toolCallStep, toolCallStep, toolCallStep])
  373. const events = Array.from(
  374. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather }, maxSteps: 2 }).pipe(
  375. Stream.runCollect,
  376. Effect.provide(layer),
  377. ),
  378. )
  379. expect(events.filter(LLMEvent.is.finish)).toHaveLength(1)
  380. expect(events.filter(LLMEvent.is.stepStart).map((event) => event.index)).toEqual([0, 1])
  381. expect(events.filter(LLMEvent.is.stepFinish).map((event) => event.index)).toEqual([0, 1])
  382. }),
  383. )
  384. it.effect("emits one final finish with aggregate usage", () =>
  385. Effect.gen(function* () {
  386. let calls = 0
  387. const events = Array.from(
  388. yield* ToolRuntime.stream({
  389. request: baseRequest,
  390. tools: { get_weather },
  391. stopWhen: ToolRuntime.stepCountIs(2),
  392. stream: () =>
  393. Stream.fromIterable<LLMEvent>(
  394. calls++ === 0
  395. ? [
  396. LLMEvent.stepStart({ index: 0 }),
  397. LLMEvent.toolCall({ id: "call_1", name: "get_weather", input: { city: "Paris" } }),
  398. LLMEvent.stepFinish({
  399. index: 0,
  400. reason: "tool-calls",
  401. usage: { inputTokens: 1, outputTokens: 2, totalTokens: 3 },
  402. }),
  403. LLMEvent.finish({
  404. reason: "tool-calls",
  405. usage: { inputTokens: 1, outputTokens: 2, totalTokens: 3 },
  406. }),
  407. ]
  408. : [
  409. LLMEvent.stepStart({ index: 0 }),
  410. LLMEvent.textDelta({ id: "text_1", text: "Done." }),
  411. LLMEvent.stepFinish({
  412. index: 0,
  413. reason: "stop",
  414. usage: { inputTokens: 4, outputTokens: 5, totalTokens: 9 },
  415. }),
  416. LLMEvent.finish({ reason: "stop", usage: { inputTokens: 4, outputTokens: 5, totalTokens: 9 } }),
  417. ],
  418. ),
  419. }).pipe(Stream.runCollect),
  420. )
  421. expect(events.filter(LLMEvent.is.stepFinish).map((event) => event.index)).toEqual([0, 1])
  422. expect(events.filter(LLMEvent.is.finish)).toHaveLength(1)
  423. expect(events.find(LLMEvent.is.finish)?.usage).toMatchObject({
  424. inputTokens: 5,
  425. outputTokens: 7,
  426. totalTokens: 12,
  427. })
  428. }),
  429. )
  430. it.effect("stops follow-up when stopWhen returns true after the first step", () =>
  431. Effect.gen(function* () {
  432. const layer = scriptedResponses([
  433. sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")),
  434. sseEvents(deltaChunk({ role: "assistant", content: "Should not run." }), finishChunk("stop")),
  435. ])
  436. const events = Array.from(
  437. yield* TestToolRuntime.runTools({
  438. request: baseRequest,
  439. tools: { get_weather },
  440. stopWhen: (state) => state.step >= 0,
  441. }).pipe(Stream.runCollect, Effect.provide(layer)),
  442. )
  443. expect(events.filter(LLMEvent.is.finish)).toHaveLength(1)
  444. expect(events.find(LLMEvent.is.toolResult)).toMatchObject({ type: "tool-result", id: "call_1" })
  445. }),
  446. )
  447. it.effect("does not dispatch provider-executed tool calls", () =>
  448. Effect.gen(function* () {
  449. let streams = 0
  450. const layer = dynamicResponse((input) =>
  451. Effect.sync(() => {
  452. streams++
  453. return input.respond(
  454. sseEvents(
  455. { type: "message_start", message: { usage: { input_tokens: 5 } } },
  456. {
  457. type: "content_block_start",
  458. index: 0,
  459. content_block: { type: "server_tool_use", id: "srvtoolu_abc", name: "web_search" },
  460. },
  461. {
  462. type: "content_block_delta",
  463. index: 0,
  464. delta: { type: "input_json_delta", partial_json: '{"query":"x"}' },
  465. },
  466. { type: "content_block_stop", index: 0 },
  467. {
  468. type: "content_block_start",
  469. index: 1,
  470. content_block: {
  471. type: "web_search_tool_result",
  472. tool_use_id: "srvtoolu_abc",
  473. content: [{ type: "web_search_result", url: "https://example.com", title: "Example" }],
  474. },
  475. },
  476. { type: "content_block_stop", index: 1 },
  477. { type: "content_block_start", index: 2, content_block: { type: "text", text: "" } },
  478. { type: "content_block_delta", index: 2, delta: { type: "text_delta", text: "Done." } },
  479. { type: "content_block_stop", index: 2 },
  480. { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 8 } },
  481. ),
  482. { headers: { "content-type": "text/event-stream" } },
  483. )
  484. }),
  485. )
  486. const events = Array.from(
  487. yield* TestToolRuntime.runTools({
  488. request: LLM.updateRequest(baseRequest, {
  489. model: AnthropicMessages.route
  490. .with({ auth: Auth.header("x-api-key", "test") })
  491. .model({ id: "claude-sonnet-4-5" }),
  492. }),
  493. tools: {},
  494. }).pipe(Stream.runCollect, Effect.provide(layer)),
  495. )
  496. expect(streams).toBe(1)
  497. expect(events.find(LLMEvent.is.toolError)).toBeUndefined()
  498. expect(events.filter(LLMEvent.is.toolCall)).toEqual([
  499. {
  500. type: "tool-call",
  501. id: "srvtoolu_abc",
  502. name: "web_search",
  503. input: { query: "x" },
  504. providerExecuted: true,
  505. },
  506. ])
  507. expect(LLMResponse.text({ events })).toBe("Done.")
  508. }),
  509. )
  510. it.effect("dispatches multiple tool calls in one step concurrently", () =>
  511. Effect.gen(function* () {
  512. const layer = scriptedResponses([
  513. sseEvents(
  514. deltaChunk({
  515. role: "assistant",
  516. tool_calls: [
  517. { index: 0, id: "c1", function: { name: "get_weather", arguments: '{"city":"Paris"}' } },
  518. { index: 1, id: "c2", function: { name: "get_weather", arguments: '{"city":"Tokyo"}' } },
  519. ],
  520. }),
  521. finishChunk("tool_calls"),
  522. ),
  523. sseEvents(deltaChunk({ role: "assistant", content: "Both done." }), finishChunk("stop")),
  524. ])
  525. const events = Array.from(
  526. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
  527. Stream.runCollect,
  528. Effect.provide(layer),
  529. ),
  530. )
  531. const results = events.filter(LLMEvent.is.toolResult)
  532. expect(results).toHaveLength(2)
  533. expect(results.map((event) => event.id).toSorted()).toEqual(["c1", "c2"])
  534. }),
  535. )
  536. })