tool-runtime.test.ts 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866
  1. import { describe, expect } from "bun:test"
  2. import { Content } from "@opencode-ai/schema/tool"
  3. import { Effect, Schema, Stream } from "effect"
  4. import {
  5. GenerationOptions,
  6. LLM,
  7. LLMEvent,
  8. LLMRequest,
  9. LLMResponse,
  10. ToolChoice,
  11. ToolOutput,
  12. toDefinitions,
  13. } from "../src"
  14. import { Auth, LLMClient } from "../src/route"
  15. import * as AnthropicMessages from "../src/protocols/anthropic-messages"
  16. import * as OpenAIChat from "../src/protocols/openai-chat"
  17. import * as OpenAIResponses from "../src/protocols/openai-responses"
  18. import { Tool, ToolFailure, type ToolExecuteContext } from "../src/tool"
  19. import { ToolRuntime } from "../src/tool-runtime"
  20. import { it } from "./lib/effect"
  21. import * as TestToolRuntime from "./lib/tool-runtime"
  22. import { dynamicResponse, scriptedResponses } from "./lib/http"
  23. import { deltaChunk, finishChunk, toolCallChunk } from "./lib/openai-chunks"
  24. import { sseEvents } from "./lib/sse"
  25. const model = OpenAIChat.route
  26. .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
  27. .model({ id: "gpt-4o-mini" })
  28. const Json = Schema.fromJsonString(Schema.Unknown)
  29. const decodeJson = Schema.decodeUnknownSync(Json)
  30. const baseRequest = LLM.request({
  31. id: "req_1",
  32. model,
  33. prompt: "Use the tool.",
  34. })
  35. const weatherFailureCause = new Error("weather lookup denied")
  36. const get_weather = Tool.make({
  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. execute: ({ city }) =>
  41. Effect.gen(function* () {
  42. if (city === "FAIL")
  43. return yield* new ToolFailure({ message: `Weather lookup failed for ${city}`, error: weatherFailureCause })
  44. return { temperature: 22, condition: "sunny" }
  45. }),
  46. })
  47. const schema_only_weather = Tool.make({
  48. description: "Get current weather for a city.",
  49. parameters: Schema.Struct({ city: Schema.String }),
  50. success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }),
  51. })
  52. describe("LLMClient tools", () => {
  53. it.effect("uses the registered model route when adding runtime tools", () =>
  54. Effect.gen(function* () {
  55. const layer = scriptedResponses([
  56. sseEvents(deltaChunk({ role: "assistant", content: "Done." }), finishChunk("stop")),
  57. ])
  58. const events = Array.from(
  59. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
  60. Stream.runCollect,
  61. Effect.provide(layer),
  62. ),
  63. )
  64. expect(LLMResponse.text({ events })).toBe("Done.")
  65. }),
  66. )
  67. it.effect("sends tool-call history and request options on the follow-up request", () =>
  68. Effect.gen(function* () {
  69. const bodies: unknown[] = []
  70. const responses = [
  71. sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")),
  72. sseEvents(deltaChunk({ role: "assistant", content: "It's sunny in Paris." }), finishChunk("stop")),
  73. ]
  74. const layer = dynamicResponse((input) =>
  75. Effect.sync(() => {
  76. bodies.push(decodeJson(input.text))
  77. return input.respond(responses[bodies.length - 1] ?? responses[responses.length - 1], {
  78. headers: { "content-type": "text/event-stream" },
  79. })
  80. }),
  81. )
  82. yield* TestToolRuntime.runTools({
  83. request: LLMRequest.update(baseRequest, {
  84. generation: GenerationOptions.make({ maxTokens: 50 }),
  85. toolChoice: ToolChoice.make("auto"),
  86. }),
  87. tools: { get_weather },
  88. }).pipe(Stream.runCollect, Effect.provide(layer))
  89. const second = bodies[1]
  90. if (!second || typeof second !== "object") throw new Error("Expected second request body")
  91. const messages = Reflect.get(second, "messages")
  92. const tools = Reflect.get(second, "tools")
  93. expect(Reflect.get(second, "max_tokens")).toBe(50)
  94. expect(Reflect.get(second, "tool_choice")).toBe("auto")
  95. expect(tools).toHaveLength(1)
  96. expect(
  97. Array.isArray(messages)
  98. ? messages.map((message) =>
  99. message && typeof message === "object" ? Reflect.get(message, "role") : undefined,
  100. )
  101. : undefined,
  102. ).toEqual(["user", "assistant", "tool"])
  103. expect(Array.isArray(messages) ? messages[1] : undefined).toMatchObject({
  104. role: "assistant",
  105. content: null,
  106. tool_calls: [{ id: "call_1", type: "function", function: { name: "get_weather" } }],
  107. })
  108. expect(Array.isArray(messages) ? messages[2] : undefined).toMatchObject({
  109. role: "tool",
  110. tool_call_id: "call_1",
  111. content: '{"temperature":22,"condition":"sunny"}',
  112. })
  113. }),
  114. )
  115. it.effect("dispatches a tool call, appends results, and resumes streaming", () =>
  116. Effect.gen(function* () {
  117. const layer = scriptedResponses([
  118. sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")),
  119. sseEvents(deltaChunk({ role: "assistant", content: "It's sunny in Paris." }), finishChunk("stop")),
  120. ])
  121. const events = Array.from(
  122. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
  123. Stream.runCollect,
  124. Effect.provide(layer),
  125. ),
  126. )
  127. const result = events.find(LLMEvent.is.toolResult)
  128. expect(result).toMatchObject({
  129. type: "tool-result",
  130. id: "call_1",
  131. name: "get_weather",
  132. result: { type: "json", value: { temperature: 22, condition: "sunny" } },
  133. })
  134. expect(events.at(-1)?.type).toBe("finish")
  135. expect(LLMResponse.text({ events })).toBe("It's sunny in Paris.")
  136. }),
  137. )
  138. it.effect("projects encoded typed tool success into canonical model content", () =>
  139. Effect.gen(function* () {
  140. const calls: unknown[] = []
  141. const projected = Tool.make({
  142. description: "Project an encoded success.",
  143. parameters: Schema.Struct({ prefix: Schema.String }),
  144. success: Schema.Struct({ count: Schema.NumberFromString }),
  145. execute: () => Effect.succeed({ count: 2 }),
  146. toModelOutput: (input) => {
  147. calls.push(input)
  148. return [{ type: "text", text: `${input.parameters.prefix}:${input.output.count}` }]
  149. },
  150. })
  151. const dispatched = yield* ToolRuntime.dispatch(
  152. { projected },
  153. LLMEvent.toolCall({ id: "call_projected", name: "projected", input: { prefix: "count" } }),
  154. )
  155. expect(calls).toEqual([{ id: "call_projected", parameters: { prefix: "count" }, output: { count: "2" } }])
  156. expect(dispatched.result).toEqual({ type: "text", value: "count:2" })
  157. expect(dispatched.output).toEqual({ structured: { count: "2" }, content: [{ type: "text", text: "count:2" }] })
  158. expect(dispatched.events).toEqual([
  159. LLMEvent.toolResult({
  160. id: "call_projected",
  161. name: "projected",
  162. result: { type: "text", value: "count:2" },
  163. output: { structured: { count: "2" }, content: [{ type: "text", text: "count:2" }] },
  164. }),
  165. ])
  166. }),
  167. )
  168. it.effect("preserves provider metadata on dispatched tool results", () =>
  169. Effect.gen(function* () {
  170. const tool = Tool.make({
  171. description: "Return text.",
  172. parameters: Schema.Struct({}),
  173. success: Schema.String,
  174. execute: () => Effect.succeed("hello"),
  175. })
  176. const providerMetadata = { google: { functionCallId: "provider_call" } }
  177. const dispatched = yield* ToolRuntime.dispatch(
  178. { tool },
  179. LLMEvent.toolCall({ id: "call_1", name: "tool", input: {}, providerMetadata }),
  180. )
  181. expect(dispatched.events).toEqual([
  182. LLMEvent.toolResult({
  183. id: "call_1",
  184. name: "tool",
  185. result: { type: "text", value: "hello" },
  186. output: { structured: "hello", content: [{ type: "text", text: "hello" }] },
  187. providerMetadata,
  188. }),
  189. ])
  190. const failed = yield* ToolRuntime.dispatch(
  191. {},
  192. LLMEvent.toolCall({ id: "call_2", name: "missing", input: {}, providerMetadata }),
  193. )
  194. expect(failed.events).toEqual([
  195. LLMEvent.toolError({
  196. id: "call_2",
  197. name: "missing",
  198. message: "Unknown tool: missing",
  199. providerMetadata,
  200. }),
  201. LLMEvent.toolResult({
  202. id: "call_2",
  203. name: "missing",
  204. result: { type: "error", value: "Unknown tool: missing" },
  205. providerMetadata,
  206. }),
  207. ])
  208. }),
  209. )
  210. it.effect("uses the narrow default projection for encoded typed success", () =>
  211. Effect.gen(function* () {
  212. const text = Tool.make({
  213. description: "Return text.",
  214. parameters: Schema.Struct({}),
  215. success: Schema.String,
  216. execute: () => Effect.succeed("hello"),
  217. })
  218. const json = Tool.make({
  219. description: "Return JSON.",
  220. parameters: Schema.Struct({}),
  221. success: Schema.Struct({ ok: Schema.Boolean }),
  222. execute: () => Effect.succeed({ ok: true }),
  223. })
  224. expect(
  225. (yield* ToolRuntime.dispatch({ text }, LLMEvent.toolCall({ id: "call_text", name: "text", input: {} }))).output,
  226. ).toEqual({ structured: "hello", content: [{ type: "text", text: "hello" }] })
  227. expect(
  228. (yield* ToolRuntime.dispatch({ json }, LLMEvent.toolCall({ id: "call_json", name: "json", input: {} }))).output,
  229. ).toEqual({ structured: { ok: true }, content: [] })
  230. }),
  231. )
  232. it.effect("can retain model media while redacting duplicated structured payloads", () =>
  233. Effect.gen(function* () {
  234. const image = Tool.make({
  235. description: "Return an image.",
  236. parameters: Schema.Struct({}),
  237. success: Schema.Struct({ mime: Schema.String, data: Schema.String }),
  238. execute: () => Effect.succeed({ mime: "image/png", data: "AAECAw==" }),
  239. toStructuredOutput: (output) => ({ mime: output.mime }),
  240. toModelOutput: ({ output }) => [
  241. { type: "file", uri: `data:${output.mime};base64,${output.data}`, mime: output.mime },
  242. ],
  243. })
  244. const dispatched = yield* ToolRuntime.dispatch(
  245. { image },
  246. LLMEvent.toolCall({ id: "call_image", name: "image", input: {} }),
  247. )
  248. expect(dispatched.output).toEqual({
  249. structured: { mime: "image/png" },
  250. content: [{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png" }],
  251. })
  252. }),
  253. )
  254. it.effect("models canonical tool files with URIs", () =>
  255. Effect.sync(() => {
  256. const decode = Schema.decodeUnknownSync(Content)
  257. expect(decode({ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" })).toEqual({
  258. type: "file",
  259. uri: "data:image/png;base64,AAAA",
  260. mime: "image/png",
  261. })
  262. expect(decode({ type: "file", uri: "https://example.test/image.png", mime: "image/png" })).toEqual({
  263. type: "file",
  264. uri: "https://example.test/image.png",
  265. mime: "image/png",
  266. })
  267. expect(decode({ type: "file", uri: "file:///tmp/image.png", mime: "image/png" })).toEqual({
  268. type: "file",
  269. uri: "file:///tmp/image.png",
  270. mime: "image/png",
  271. })
  272. }),
  273. )
  274. it.effect("preserves canonical tool file URIs", () =>
  275. Effect.sync(() => {
  276. expect(
  277. ToolOutput.toResultValue(
  278. ToolOutput.make({}, [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }]),
  279. ),
  280. ).toEqual({
  281. type: "content",
  282. value: [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }],
  283. })
  284. expect(
  285. ToolOutput.toResultValue(
  286. ToolOutput.make({}, [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }]),
  287. ),
  288. ).toEqual({
  289. type: "content",
  290. value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
  291. })
  292. expect(
  293. ToolOutput.toResultValue(
  294. ToolOutput.make({}, [{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" }]),
  295. ),
  296. ).toEqual({
  297. type: "content",
  298. value: [{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" }],
  299. })
  300. expect(
  301. ToolOutput.fromResultValue({
  302. type: "content",
  303. value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
  304. }),
  305. ).toEqual({
  306. structured: {},
  307. content: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
  308. })
  309. }),
  310. )
  311. it.effect("settles projected URL files as canonical tool results", () =>
  312. Effect.gen(function* () {
  313. const remote = Tool.make({
  314. description: "Return a remote file.",
  315. parameters: Schema.Struct({}),
  316. success: Schema.Struct({ ok: Schema.Boolean }),
  317. execute: () => Effect.succeed({ ok: true }),
  318. toModelOutput: () => [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
  319. })
  320. const dispatched = yield* ToolRuntime.dispatch(
  321. { remote },
  322. LLMEvent.toolCall({ id: "call_remote", name: "remote", input: {} }),
  323. )
  324. expect(dispatched.output).toEqual({
  325. structured: { ok: true },
  326. content: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
  327. })
  328. expect(dispatched.result).toEqual({
  329. type: "content",
  330. value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
  331. })
  332. expect(dispatched.events.map((event) => event.type)).toEqual(["tool-result"])
  333. }),
  334. )
  335. it.effect("derives typed output schemas and preserves dynamic output schemas", () =>
  336. Effect.sync(() => {
  337. const [typed] = toDefinitions({ get_weather })
  338. const schema = { type: "object", properties: { result: { type: "string" } } } as const
  339. const [dynamic] = toDefinitions({
  340. dynamic: Tool.make({ description: "Dynamic tool.", jsonSchema: { type: "object" }, outputSchema: schema }),
  341. })
  342. expect(typed?.outputSchema).toMatchObject({
  343. type: "object",
  344. properties: { condition: { type: "string" } },
  345. required: ["temperature", "condition"],
  346. additionalProperties: false,
  347. })
  348. expect(Reflect.get(Reflect.get(typed?.outputSchema ?? {}, "properties") as object, "temperature")).toBeDefined()
  349. expect(dynamic?.outputSchema).toEqual(schema)
  350. }),
  351. )
  352. it.effect("preserves content tool results from dynamic tools", () =>
  353. Effect.gen(function* () {
  354. const screenshot = Tool.make({
  355. description: "Capture a screenshot.",
  356. jsonSchema: { type: "object", properties: {} },
  357. execute: () =>
  358. Effect.succeed({
  359. type: "content" as const,
  360. value: [
  361. { type: "text" as const, text: "Screenshot captured." },
  362. { type: "file" as const, uri: "data:image/png;base64,AAAA", mime: "image/png" },
  363. ],
  364. }),
  365. })
  366. const events = Array.from(
  367. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { screenshot }, maxSteps: 1 }).pipe(
  368. Stream.runCollect,
  369. Effect.provide(
  370. scriptedResponses([sseEvents(toolCallChunk("call_1", "screenshot", "{}"), finishChunk("tool_calls"))]),
  371. ),
  372. ),
  373. )
  374. expect(events.find(LLMEvent.is.toolResult)).toMatchObject({
  375. type: "tool-result",
  376. id: "call_1",
  377. name: "screenshot",
  378. result: {
  379. type: "content",
  380. value: [
  381. { type: "text", text: "Screenshot captured." },
  382. { type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" },
  383. ],
  384. },
  385. })
  386. }),
  387. )
  388. it.effect("does not mistake dynamic tool output fields for dispatcher state", () =>
  389. Effect.gen(function* () {
  390. const callerOwned = { type: "json" as const, value: { ok: true }, events: ["caller-owned"] }
  391. const eventful = Tool.make({
  392. description: "Return an events field.",
  393. jsonSchema: { type: "object", properties: {} },
  394. execute: () => Effect.succeed(callerOwned),
  395. })
  396. const dispatched = yield* ToolRuntime.dispatch(
  397. { eventful },
  398. LLMEvent.toolCall({ id: "call_1", name: "eventful", input: {} }),
  399. )
  400. expect(dispatched.result).toEqual(callerOwned)
  401. expect(dispatched.events).toEqual([
  402. LLMEvent.toolResult({
  403. id: "call_1",
  404. name: "eventful",
  405. result: callerOwned,
  406. output: { structured: { ok: true }, content: [] },
  407. }),
  408. ])
  409. }),
  410. )
  411. it.effect("executes tool calls for one step without looping by default", () =>
  412. Effect.gen(function* () {
  413. const layer = scriptedResponses([
  414. sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")),
  415. sseEvents(deltaChunk({ role: "assistant", content: "Should not run." }), finishChunk("stop")),
  416. ])
  417. const events = Array.from(
  418. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather }, maxSteps: 1 }).pipe(
  419. Stream.runCollect,
  420. Effect.provide(layer),
  421. ),
  422. )
  423. expect(events.filter(LLMEvent.is.finish)).toHaveLength(1)
  424. expect(events.find(LLMEvent.is.toolResult)).toMatchObject({ type: "tool-result", id: "call_1" })
  425. }),
  426. )
  427. it.effect("passes tool call context to execute", () =>
  428. Effect.gen(function* () {
  429. let context: ToolExecuteContext | undefined
  430. const contextual = Tool.make({
  431. description: "Capture tool context.",
  432. parameters: Schema.Struct({ value: Schema.String }),
  433. success: Schema.Struct({ ok: Schema.Boolean }),
  434. execute: (_params, ctx) =>
  435. Effect.sync(() => {
  436. context = ctx
  437. return { ok: true }
  438. }),
  439. })
  440. const events = Array.from(
  441. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { contextual } }).pipe(
  442. Stream.runCollect,
  443. Effect.provide(
  444. scriptedResponses([
  445. sseEvents(toolCallChunk("call_ctx", "contextual", '{"value":"x"}'), finishChunk("tool_calls")),
  446. ]),
  447. ),
  448. ),
  449. )
  450. expect(events.some(LLMEvent.is.toolResult)).toBe(true)
  451. expect(context).toEqual({ id: "call_ctx", name: "contextual" })
  452. }),
  453. )
  454. it.effect("can expose tool schemas without executing tool calls", () =>
  455. Effect.gen(function* () {
  456. const layer = scriptedResponses([
  457. sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")),
  458. ])
  459. const events = Array.from(
  460. yield* LLMClient.stream(
  461. LLMRequest.update(baseRequest, { tools: toDefinitions({ get_weather: schema_only_weather }) }),
  462. ).pipe(Stream.runCollect, Effect.provide(layer)),
  463. )
  464. expect(events.find(LLMEvent.is.toolCall)).toMatchObject({ type: "tool-call", id: "call_1" })
  465. expect(events.find(LLMEvent.is.toolResult)).toBeUndefined()
  466. }),
  467. )
  468. it.effect("preserves provider metadata when folding streamed assistant content into follow-up history", () =>
  469. Effect.gen(function* () {
  470. const bodies: unknown[] = []
  471. const layer = dynamicResponse((input) =>
  472. Effect.sync(() => {
  473. bodies.push(decodeJson(input.text))
  474. return input.respond(
  475. bodies.length === 1
  476. ? sseEvents(
  477. { type: "message_start", message: { usage: { input_tokens: 5 } } },
  478. { type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "" } },
  479. { type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "thinking" } },
  480. { type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "sig_1" } },
  481. { type: "content_block_stop", index: 0 },
  482. {
  483. type: "content_block_start",
  484. index: 1,
  485. content_block: { type: "tool_use", id: "call_1", name: "get_weather" },
  486. },
  487. {
  488. type: "content_block_delta",
  489. index: 1,
  490. delta: { type: "input_json_delta", partial_json: '{"city":"Paris"}' },
  491. },
  492. { type: "content_block_stop", index: 1 },
  493. { type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 5 } },
  494. { type: "message_stop" },
  495. )
  496. : sseEvents(
  497. { type: "message_start", message: { usage: { input_tokens: 5 } } },
  498. { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
  499. { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Done." } },
  500. { type: "content_block_stop", index: 0 },
  501. { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
  502. { type: "message_stop" },
  503. ),
  504. { headers: { "content-type": "text/event-stream" } },
  505. )
  506. }),
  507. )
  508. yield* TestToolRuntime.runTools({
  509. request: LLMRequest.update(baseRequest, {
  510. model: AnthropicMessages.route
  511. .with({ auth: Auth.header("x-api-key", "test") })
  512. .model({ id: "claude-sonnet-4-5" }),
  513. }),
  514. tools: { get_weather },
  515. }).pipe(Stream.runCollect, Effect.provide(layer))
  516. expect(bodies[1]).toMatchObject({
  517. messages: [
  518. { role: "user" },
  519. {
  520. role: "assistant",
  521. content: [
  522. { type: "thinking", thinking: "thinking", signature: "sig_1" },
  523. { type: "tool_use", id: "call_1", name: "get_weather", input: { city: "Paris" } },
  524. ],
  525. },
  526. { role: "user", content: [{ type: "tool_result", tool_use_id: "call_1" }] },
  527. ],
  528. })
  529. }),
  530. )
  531. it.effect("replays encrypted OpenAI reasoning items with tool outputs", () =>
  532. Effect.gen(function* () {
  533. const bodies: unknown[] = []
  534. const layer = dynamicResponse((input) =>
  535. Effect.sync(() => {
  536. bodies.push(decodeJson(input.text))
  537. return input.respond(
  538. bodies.length === 1
  539. ? sseEvents(
  540. {
  541. type: "response.output_item.added",
  542. item: { type: "reasoning", id: "rs_1", encrypted_content: null },
  543. },
  544. { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
  545. { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
  546. {
  547. type: "response.output_item.done",
  548. item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
  549. },
  550. {
  551. type: "response.output_item.added",
  552. item: {
  553. type: "function_call",
  554. id: "item_1",
  555. call_id: "call_1",
  556. name: "get_weather",
  557. arguments: "",
  558. },
  559. },
  560. { type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"city":"Paris"}' },
  561. {
  562. type: "response.output_item.done",
  563. item: {
  564. type: "function_call",
  565. id: "item_1",
  566. call_id: "call_1",
  567. name: "get_weather",
  568. arguments: '{"city":"Paris"}',
  569. },
  570. },
  571. { type: "response.completed", response: {} },
  572. )
  573. : sseEvents(
  574. { type: "response.output_text.delta", item_id: "msg_1", delta: "Done." },
  575. { type: "response.completed", response: {} },
  576. ),
  577. { headers: { "content-type": "text/event-stream" } },
  578. )
  579. }),
  580. )
  581. yield* TestToolRuntime.runTools({
  582. request: LLM.request({
  583. model: OpenAIResponses.route
  584. .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
  585. .model({ id: "gpt-5.5" }),
  586. prompt: "Use the tool.",
  587. providerOptions: { openai: { store: false, include: ["reasoning.encrypted_content"] } },
  588. }),
  589. tools: { get_weather },
  590. }).pipe(Stream.runCollect, Effect.provide(layer))
  591. expect(bodies[1]).toMatchObject({
  592. include: ["reasoning.encrypted_content"],
  593. input: [
  594. { role: "user" },
  595. { type: "reasoning", summary: [], encrypted_content: "encrypted-state" },
  596. { type: "function_call", call_id: "call_1", name: "get_weather" },
  597. { type: "function_call_output", call_id: "call_1" },
  598. ],
  599. })
  600. }),
  601. )
  602. it.effect("emits tool-error for unknown tools so the model can self-correct", () =>
  603. Effect.gen(function* () {
  604. const layer = scriptedResponses([
  605. sseEvents(toolCallChunk("call_1", "missing_tool", "{}"), finishChunk("tool_calls")),
  606. sseEvents(deltaChunk({ role: "assistant", content: "Sorry." }), finishChunk("stop")),
  607. ])
  608. const events = Array.from(
  609. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
  610. Stream.runCollect,
  611. Effect.provide(layer),
  612. ),
  613. )
  614. const toolError = events.find(LLMEvent.is.toolError)
  615. expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "missing_tool" })
  616. expect(toolError?.message).toContain("Unknown tool")
  617. expect(events.find(LLMEvent.is.toolResult)).toMatchObject({
  618. type: "tool-result",
  619. id: "call_1",
  620. name: "missing_tool",
  621. result: { type: "error", value: "Unknown tool: missing_tool" },
  622. })
  623. }),
  624. )
  625. it.effect("emits tool-error when the LLM input fails the parameters schema", () =>
  626. Effect.gen(function* () {
  627. const layer = scriptedResponses([
  628. sseEvents(toolCallChunk("call_1", "get_weather", '{"city":42}'), finishChunk("tool_calls")),
  629. sseEvents(deltaChunk({ role: "assistant", content: "Done." }), finishChunk("stop")),
  630. ])
  631. const events = Array.from(
  632. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
  633. Stream.runCollect,
  634. Effect.provide(layer),
  635. ),
  636. )
  637. const toolError = events.find(LLMEvent.is.toolError)
  638. expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "get_weather" })
  639. expect(toolError?.message).toContain("Invalid tool input")
  640. }),
  641. )
  642. it.effect("emits tool-error when the handler returns a ToolFailure", () =>
  643. Effect.gen(function* () {
  644. const layer = scriptedResponses([
  645. sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"FAIL"}'), finishChunk("tool_calls")),
  646. sseEvents(deltaChunk({ role: "assistant", content: "Sorry." }), finishChunk("stop")),
  647. ])
  648. const events = Array.from(
  649. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
  650. Stream.runCollect,
  651. Effect.provide(layer),
  652. ),
  653. )
  654. const toolError = events.find(LLMEvent.is.toolError)
  655. expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "get_weather" })
  656. expect(toolError?.message).toBe("Weather lookup failed for FAIL")
  657. expect(toolError?.error).toBe(weatherFailureCause)
  658. }),
  659. )
  660. it.effect("stops when the model finishes without requesting more tools", () =>
  661. Effect.gen(function* () {
  662. const layer = scriptedResponses([
  663. sseEvents(deltaChunk({ role: "assistant", content: "Done." }), finishChunk("stop")),
  664. ])
  665. const events = Array.from(
  666. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
  667. Stream.runCollect,
  668. Effect.provide(layer),
  669. ),
  670. )
  671. expect(events.map((event) => event.type)).toEqual([
  672. "step-start",
  673. "text-start",
  674. "text-delta",
  675. "text-end",
  676. "step-finish",
  677. "finish",
  678. ])
  679. expect(LLMResponse.text({ events })).toBe("Done.")
  680. }),
  681. )
  682. it.effect("respects maxSteps and stops the loop", () =>
  683. Effect.gen(function* () {
  684. // Every script entry asks for another tool call. With maxSteps: 2 the
  685. // runtime should run at most two model rounds and then exit even though
  686. // the model still wants to keep going.
  687. const toolCallStep = sseEvents(
  688. toolCallChunk("call_x", "get_weather", '{"city":"Paris"}'),
  689. finishChunk("tool_calls"),
  690. )
  691. const layer = scriptedResponses([toolCallStep, toolCallStep, toolCallStep])
  692. const events = Array.from(
  693. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather }, maxSteps: 2 }).pipe(
  694. Stream.runCollect,
  695. Effect.provide(layer),
  696. ),
  697. )
  698. expect(events.filter(LLMEvent.is.finish)).toHaveLength(1)
  699. expect(events.filter(LLMEvent.is.stepStart).map((event) => event.index)).toEqual([0, 1])
  700. expect(events.filter(LLMEvent.is.stepFinish).map((event) => event.index)).toEqual([0, 1])
  701. }),
  702. )
  703. it.effect("does not dispatch provider-executed tool calls", () =>
  704. Effect.gen(function* () {
  705. let streams = 0
  706. const layer = dynamicResponse((input) =>
  707. Effect.sync(() => {
  708. streams++
  709. return input.respond(
  710. sseEvents(
  711. { type: "message_start", message: { usage: { input_tokens: 5 } } },
  712. {
  713. type: "content_block_start",
  714. index: 0,
  715. content_block: { type: "server_tool_use", id: "srvtoolu_abc", name: "web_search" },
  716. },
  717. {
  718. type: "content_block_delta",
  719. index: 0,
  720. delta: { type: "input_json_delta", partial_json: '{"query":"x"}' },
  721. },
  722. { type: "content_block_stop", index: 0 },
  723. {
  724. type: "content_block_start",
  725. index: 1,
  726. content_block: {
  727. type: "web_search_tool_result",
  728. tool_use_id: "srvtoolu_abc",
  729. content: [{ type: "web_search_result", url: "https://example.com", title: "Example" }],
  730. },
  731. },
  732. { type: "content_block_stop", index: 1 },
  733. { type: "content_block_start", index: 2, content_block: { type: "text", text: "" } },
  734. { type: "content_block_delta", index: 2, delta: { type: "text_delta", text: "Done." } },
  735. { type: "content_block_stop", index: 2 },
  736. { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 8 } },
  737. { type: "message_stop" },
  738. ),
  739. { headers: { "content-type": "text/event-stream" } },
  740. )
  741. }),
  742. )
  743. const events = Array.from(
  744. yield* TestToolRuntime.runTools({
  745. request: LLMRequest.update(baseRequest, {
  746. model: AnthropicMessages.route
  747. .with({ auth: Auth.header("x-api-key", "test") })
  748. .model({ id: "claude-sonnet-4-5" }),
  749. }),
  750. tools: {},
  751. }).pipe(Stream.runCollect, Effect.provide(layer)),
  752. )
  753. expect(streams).toBe(1)
  754. expect(events.find(LLMEvent.is.toolError)).toBeUndefined()
  755. expect(events.filter(LLMEvent.is.toolCall)).toEqual([
  756. {
  757. type: "tool-call",
  758. id: "srvtoolu_abc",
  759. name: "web_search",
  760. input: { query: "x" },
  761. providerExecuted: true,
  762. },
  763. ])
  764. expect(LLMResponse.text({ events })).toBe("Done.")
  765. }),
  766. )
  767. it.effect("dispatches multiple tool calls in one step concurrently", () =>
  768. Effect.gen(function* () {
  769. const layer = scriptedResponses([
  770. sseEvents(
  771. deltaChunk({
  772. role: "assistant",
  773. tool_calls: [
  774. { index: 0, id: "c1", function: { name: "get_weather", arguments: '{"city":"Paris"}' } },
  775. { index: 1, id: "c2", function: { name: "get_weather", arguments: '{"city":"Tokyo"}' } },
  776. ],
  777. }),
  778. finishChunk("tool_calls"),
  779. ),
  780. sseEvents(deltaChunk({ role: "assistant", content: "Both done." }), finishChunk("stop")),
  781. ])
  782. const events = Array.from(
  783. yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
  784. Stream.runCollect,
  785. Effect.provide(layer),
  786. ),
  787. )
  788. const results = events.filter(LLMEvent.is.toolResult)
  789. expect(results).toHaveLength(2)
  790. expect(results.map((event) => event.id).toSorted()).toEqual(["c1", "c2"])
  791. }),
  792. )
  793. })