tool-runtime.test.ts 25 KB

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