1import { describe, it } from "@effect/vitest"
2import { assertDefined, assertTrue, deepStrictEqual, strictEqual } from "@effect/vitest/utils"
3import { type Cause, Effect, Fiber, Latch, Option, Queue, Ref, Schema, Stream } from "effect"
4import { AiError, LanguageModel, Prompt, Response, ResponseIdTracker, Tool, Toolkit } from "effect/ai"
5import { TestClock } from "effect/testing"
6import * as TestUtils from "./utils.ts"
7
8const MyTool = Tool.make("MyTool", {
9 parameters: Schema.Struct({ testParam: Schema.String }),
10 success: Schema.Struct({ testSuccess: Schema.String })
11})
12
13const MyToolkit = Toolkit.make(MyTool)
14
15const MyToolkitLayer = MyToolkit.toLayer({
16 MyTool: () =>
17 Effect.succeed({ testSuccess: "test-success" }).pipe(
18 Effect.delay("10 seconds")
19 )
20})
21
22const TransformTool = Tool.make("TransformTool", {
23 parameters: Schema.FiniteFromString,
24 success: Schema.Finite
25})
26
27const TransformToolkit = Toolkit.make(TransformTool)
28
29const TransformToolkitLayer = TransformToolkit.toLayer({
30 TransformTool: (value) => Effect.succeed(value * 2)
31})
32
33const ReturnModeTool = Tool.make("ReturnModeTool", {
34 failureMode: "return",
35 parameters: Schema.Struct({ testParam: Schema.String }),
36 success: Schema.Struct({ testSuccess: Schema.String }),
37 failure: Schema.Struct({ testFailure: Schema.String })
38})
39
40const ReturnModeToolkit = Toolkit.make(ReturnModeTool)
41
42const ApprovalTool = Tool.make("ApprovalTool", {
43 parameters: Schema.Struct({ action: Schema.String }),
44 success: Schema.Struct({ result: Schema.String }),
45 needsApproval: true
46})
47
48const DynamicApprovalTool = Tool.make("DynamicApprovalTool", {
49 parameters: Schema.Struct({ dangerous: Schema.Boolean }),
50 success: Schema.Struct({ result: Schema.String }),
51 needsApproval: (params) => params.dangerous
52})
53
54const ApprovalToolkit = Toolkit.make(ApprovalTool, DynamicApprovalTool)
55
56const ApprovalToolkitLayer = ApprovalToolkit.toLayer({
57 ApprovalTool: () => Effect.succeed({ result: "approved-result" }),
58 DynamicApprovalTool: () => Effect.succeed({ result: "dynamic-result" })
59})
60
61describe("LanguageModel", () => {
62 const finishPart: Response.FinishPartEncoded = {
63 type: "finish",
64 reason: "stop",
65 usage: {
66 inputTokens: { uncached: 5, total: 5, cacheRead: undefined, cacheWrite: undefined },
67 outputTokens: { total: 5, text: undefined, reasoning: undefined }
68 },
69 response: undefined
70 }
71
72 describe("generateText", () => {
73 it.effect("does not resolve tool calls after an incomplete finish", () =>
74 Effect.gen(function*() {
75 const calls = yield* Ref.make(0)
76 const handlers = MyToolkit.toLayer({
77 MyTool: () =>
78 Ref.update(calls, (n) => n + 1).pipe(
79 Effect.as({ testSuccess: "test-success" })
80 )
81 })
82
83 for (const reason of ["length", "content-filter", "error", "unknown", "other"] as const) {
84 const response = yield* LanguageModel.generateText({
85 prompt: [],
86 toolkit: MyToolkit
87 }).pipe(
88 TestUtils.withLanguageModel({
89 generateText: [
90 {
91 type: "tool-call",
92 id: `tool-${reason}`,
93 name: "MyTool",
94 params: { testParam: "test-param" }
95 },
96 { ...finishPart, reason }
97 ]
98 }),
99 Effect.provide(handlers)
100 )
101
102 strictEqual(response.finishReason, reason)
103 strictEqual(response.toolCalls.length, 1)
104 strictEqual(response.toolResults.length, 1)
105
106 const toolResult = response.toolResults[0]!
107 strictEqual(toolResult.isFailure, true)
108 deepStrictEqual<unknown>(toolResult.result, {
109 type: "execution-interrupted",
110 reason: `Tool call execution was interrupted because the response finished with reason "${reason}"`
111 })
112 }
113
114 strictEqual(yield* Ref.get(calls), 0)
115 }))
116
117 it.effect("validates the complete response before resolving tool calls", () =>
118 Effect.gen(function*() {
119 const calls = yield* Ref.make(0)
120 const handlers = MyToolkit.toLayer({
121 MyTool: () =>
122 Ref.update(calls, (n) => n + 1).pipe(
123 Effect.as({ testSuccess: "test-success" })
124 )
125 })
126
127 const error = yield* LanguageModel.generateText({
128 prompt: [],
129 toolkit: MyToolkit
130 }).pipe(
131 TestUtils.withLanguageModel({
132 generateText: [
133 {
134 type: "tool-call",
135 id: "tool-before-invalid-part",
136 name: "MyTool",
137 params: { testParam: "test-param" }
138 },
139 { type: "text", text: 123 } as any
140 ]
141 }),
142 Effect.provide(handlers),
143 Effect.flip
144 )
145
146 strictEqual(error.reason._tag, "InvalidOutputError")
147 strictEqual(yield* Ref.get(calls), 0)
148 }))
149
150 it.effect("fails cleanly for a tool call with a missing params field", () =>
151 Effect.gen(function*() {
152 const calls = yield* Ref.make(0)
153 const handlers = MyToolkit.toLayer({
154 MyTool: () =>
155 Ref.update(calls, (n) => n + 1).pipe(
156 Effect.as({ testSuccess: "test-success" })
157 )
158 })
159
160 const error = yield* LanguageModel.generateText({
161 prompt: [],
162 toolkit: MyToolkit
163 }).pipe(
164 TestUtils.withLanguageModel({
165 generateText: [
166 { type: "tool-call", id: "tool-no-params", name: "MyTool" } as any,
167 finishPart
168 ]
169 }),
170 Effect.provide(handlers),
171 Effect.flip
172 )
173
174 strictEqual(error.reason._tag, "InvalidOutputError")
175 strictEqual(yield* Ref.get(calls), 0)
176 }))
177
178 it.effect("validates encoded tool parameters when tool call resolution is disabled", () =>
179 Effect.gen(function*() {
180 const error = yield* LanguageModel.generateText({
181 prompt: [],
182 toolkit: TransformToolkit,
183 disableToolCallResolution: true
184 }).pipe(
185 TestUtils.withLanguageModel({
186 generateText: [{
187 type: "tool-call",
188 id: "tool-invalid-transform",
189 name: "TransformTool",
190 params: { invalid: true }
191 }]
192 }),
193 Effect.provide(TransformToolkitLayer),
194 Effect.flip
195 )
196
197 strictEqual(error.reason._tag, "InvalidOutputError")
198 }))
199
200 it.effect("preserves encoded tool parameters when tool call resolution is disabled", () =>
201 Effect.gen(function*() {
202 const response = yield* LanguageModel.generateText({
203 prompt: [],
204 toolkit: TransformToolkit,
205 disableToolCallResolution: true
206 })
207 const toolCall = response.toolCalls[0]!
208
209 strictEqual(toolCall.params, "21")
210
211 const toolkit = yield* TransformToolkit
212 const results = yield* toolkit.handle(toolCall.name, toolCall.params).pipe(
213 Effect.flatMap(Stream.runCollect)
214 )
215
216 strictEqual(results[0].result, 42)
217 }).pipe(
218 TestUtils.withLanguageModel({
219 generateText: [{
220 type: "tool-call",
221 id: "tool-transform",
222 name: "TransformTool",
223 params: "21"
224 }]
225 }),
226 Effect.provide(TransformToolkitLayer)
227 ))
228
229 it.effect("preserves encoded tool parameters when tool call resolution is enabled", () =>
230 Effect.gen(function*() {
231 const response = yield* LanguageModel.generateText({
232 prompt: [],
233 toolkit: TransformToolkit
234 })
235
236 const toolCall = response.toolCalls[0]!
237 strictEqual(toolCall.params, "21")
238
239 const toolResult = response.toolResults[0]!
240 strictEqual(toolResult.isFailure, false)
241 strictEqual(toolResult.result, 42)
242 }).pipe(
243 TestUtils.withLanguageModel({
244 generateText: [{
245 type: "tool-call",
246 id: "tool-transform",
247 name: "TransformTool",
248 params: "21"
249 }]
250 }),
251 Effect.provide(TransformToolkitLayer)
252 ))
253
254 it.effect("validates tool parameters against the schemas of each request", () =>
255 Effect.gen(function*() {
256 const StructTransformToolkit = Toolkit.make(Tool.make("TransformTool", {
257 parameters: Schema.Struct({ value: Schema.Finite }),
258 success: Schema.Finite
259 }))
260
261 yield* LanguageModel.generateText({
262 prompt: [],
263 toolkit: TransformToolkit,
264 disableToolCallResolution: true
265 }).pipe(Effect.provide(TransformToolkitLayer))
266
267 const error = yield* LanguageModel.generateText({
268 prompt: [],
269 toolkit: StructTransformToolkit,
270 disableToolCallResolution: true
271 }).pipe(
272 Effect.provide(StructTransformToolkit.toLayer({ TransformTool: ({ value }) => Effect.succeed(value) })),
273 Effect.flip
274 )
275
276 strictEqual(error.reason._tag, "InvalidOutputError")
277 }).pipe(
278 TestUtils.withLanguageModel({
279 generateText: [{
280 type: "tool-call",
281 id: "tool-transform",
282 name: "TransformTool",
283 params: "21"
284 }]
285 })
286 ))
287
288 it.effect("validates encoded tool parameters after a request with tool call resolution enabled", () => {
289 // A tool unused by other tests, so the opaque request decodes it first
290 const toolkit = Toolkit.make(Tool.make("TransformTool", {
291 parameters: Schema.FiniteFromString,
292 success: Schema.Finite
293 }))
294 const responses: Array<Array<Response.PartEncoded>> = [
295 [{ type: "tool-call", id: "tool-valid", name: "TransformTool", params: "21" }],
296 [{ type: "tool-call", id: "tool-invalid", name: "TransformTool", params: { invalid: true } }]
297 ]
298 return Effect.gen(function*() {
299 yield* LanguageModel.generateText({
300 prompt: [],
301 toolkit
302 })
303
304 const error = yield* LanguageModel.generateText({
305 prompt: [],
306 toolkit,
307 disableToolCallResolution: true
308 }).pipe(Effect.flip)
309
310 strictEqual(error.reason._tag, "InvalidOutputError")
311 }).pipe(
312 TestUtils.withLanguageModel({
313 generateText: () => responses.shift()!
314 }),
315 Effect.provide(toolkit.toLayer({ TransformTool: (value) => Effect.succeed(value * 2) }))
316 )
317 })
318
319 it.effect("validates provider-executed tool call parameters", () =>
320 Effect.gen(function*() {
321 const calls = yield* Ref.make(0)
322 const handlers = MyToolkit.toLayer({
323 MyTool: () =>
324 Ref.update(calls, (n) => n + 1).pipe(
325 Effect.as({ testSuccess: "test-success" })
326 )
327 })
328
329 const error = yield* LanguageModel.generateText({
330 prompt: [],
331 toolkit: MyToolkit
332 }).pipe(
333 TestUtils.withLanguageModel({
334 generateText: [
335 {
336 type: "tool-call",
337 id: "tool-provider-executed",
338 name: "MyTool",
339 providerExecuted: true,
340 params: { testParam: 123 }
341 } as any,
342 finishPart
343 ]
344 }),
345 Effect.provide(handlers),
346 Effect.flip
347 )
348
349 strictEqual(error.reason._tag, "InvalidOutputError")
350 strictEqual(yield* Ref.get(calls), 0)
351 }))
352
353 it.effect("accepts provider-executed tool calls with valid parameters", () =>
354 Effect.gen(function*() {
355 const calls = yield* Ref.make(0)
356 const handlers = MyToolkit.toLayer({
357 MyTool: () =>
358 Ref.update(calls, (n) => n + 1).pipe(
359 Effect.as({ testSuccess: "test-success" })
360 )
361 })
362
363 const response = yield* LanguageModel.generateText({
364 prompt: [],
365 toolkit: MyToolkit
366 }).pipe(
367 TestUtils.withLanguageModel({
368 generateText: [
369 {
370 type: "tool-call",
371 id: "tool-provider-executed",
372 name: "MyTool",
373 providerExecuted: true,
374 params: { testParam: "test-param" }
375 },
376 finishPart
377 ]
378 }),
379 Effect.provide(handlers)
380 )
381
382 strictEqual(response.toolCalls.length, 1)
383 deepStrictEqual(response.toolCalls[0]!.params, { testParam: "test-param" })
384 strictEqual(yield* Ref.get(calls), 0)
385 }))
386 })
387
388 describe("streamText", () => {
389 it.effect("interrupts in-flight tool handlers on an incomplete finish", () =>
390 Effect.gen(function*() {
391 const toolCallObserved = yield* Latch.make()
392 const handlerStarted = yield* Latch.make()
393 const handlerInterrupted = yield* Latch.make()
394 const handlers = MyToolkit.toLayer({
395 MyTool: () =>
396 handlerStarted.open.pipe(
397 Effect.andThen(Effect.never),
398 Effect.onInterrupt(() => handlerInterrupted.open),
399 Effect.as({ testSuccess: "test-success" })
400 )
401 })
402 const providerQueue = yield* Queue.make<Response.StreamPartEncoded, Cause.Done>()
403 const parts: Array<Response.StreamPart<Toolkit.Tools<typeof MyToolkit>, "opaque">> = []
404
405 const fiber = yield* LanguageModel.streamText({
406 prompt: [],
407 toolkit: MyToolkit
408 }).pipe(
409 Stream.runForEach((part) =>
410 Effect.sync(() => {
411 parts.push(part)
412 }).pipe(
413 Effect.andThen(part.type === "tool-call" ? toolCallObserved.open : Effect.void)
414 )
415 ),
416 TestUtils.withLanguageModel({
417 streamText: () => Stream.fromQueue(providerQueue)
418 }),
419 Effect.provide(handlers),
420 Effect.forkScoped
421 )
422
423 yield* Queue.offer(providerQueue, {
424 type: "tool-call",
425 id: "tool-interrupted",
426 name: "MyTool",
427 params: { testParam: "test-param" }
428 })
429 yield* toolCallObserved.await
430 // The handler starts once the stream moves past the tool call
431 yield* Queue.offer(providerQueue, { type: "text-delta", id: "text-1", delta: "more" })
432 yield* handlerStarted.await
433
434 yield* Queue.offer(providerQueue, { ...finishPart, reason: "length" })
435 yield* Queue.end(providerQueue)
436 yield* Fiber.join(fiber)
437
438 yield* handlerInterrupted.await
439 deepStrictEqual(parts.map((part) => part.type), ["tool-call", "text-delta", "tool-result", "finish"])
440 strictEqual(parts.find((part) => part.type === "finish")?.reason, "length")
441
442 // The interrupted handler produced no result, so a synthesized
443 // failure result resolves the tool call instead
444 const toolResult = parts.find((part) => part.type === "tool-result")!
445 strictEqual(toolResult.id, "tool-interrupted")
446 strictEqual(toolResult.isFailure, true)
447 deepStrictEqual<unknown>(toolResult.result, {
448 type: "execution-interrupted",
449 reason: `Tool call execution was interrupted because the response finished with reason "length"`
450 })
451 }))
452
453 it.effect("does not start tool handlers when an incomplete finish arrives in the same chunk", () =>
454 Effect.gen(function*() {
455 const calls = yield* Ref.make(0)
456 const handlers = MyToolkit.toLayer({
457 MyTool: () =>
458 Ref.update(calls, (n) => n + 1).pipe(
459 Effect.as({ testSuccess: "test-success" })
460 )
461 })
462
463 for (const reason of ["length", "content-filter", "error", "unknown", "other"] as const) {
464 const parts = yield* LanguageModel.streamText({
465 prompt: [],
466 toolkit: MyToolkit
467 }).pipe(
468 Stream.runCollect,
469 TestUtils.withLanguageModel({
470 streamText: [
471 {
472 type: "tool-call",
473 id: `tool-${reason}`,
474 name: "MyTool",
475 params: { testParam: "test-param" }
476 },
477 { ...finishPart, reason }
478 ]
479 }),
480 Effect.provide(handlers)
481 )
482
483 deepStrictEqual(parts.map((part) => part.type), ["tool-call", "tool-result", "finish"])
484
485 const toolResult = parts.find((part) => part.type === "tool-result")!
486 strictEqual(toolResult.isFailure, true)
487 deepStrictEqual<unknown>(toolResult.result, {
488 type: "execution-interrupted",
489 reason: `Tool call execution was interrupted because the response finished with reason "${reason}"`
490 })
491 }
492
493 strictEqual(yield* Ref.get(calls), 0)
494 }))
495
496 it.effect("does not start tool handlers when an incomplete finish arrives in the next chunk", () =>
497 Effect.gen(function*() {
498 const calls = yield* Ref.make(0)
499 const handlers = MyToolkit.toLayer({
500 MyTool: () =>
501 Ref.update(calls, (n) => n + 1).pipe(
502 Effect.as({ testSuccess: "test-success" })
503 )
504 })
505 const providerQueue = yield* Queue.make<Response.StreamPartEncoded, Cause.Done>()
506
507 const fiber = yield* LanguageModel.streamText({
508 prompt: [],
509 toolkit: MyToolkit
510 }).pipe(
511 Stream.runCollect,
512 TestUtils.withLanguageModel({
513 streamText: () => Stream.fromQueue(providerQueue)
514 }),
515 Effect.provide(handlers),
516 Effect.forkScoped
517 )
518
519 // The tool call and the truncating finish arrive back-to-back as
520 // separate chunks - the handler must never start
521 yield* Queue.offer(providerQueue, {
522 type: "tool-call",
523 id: "tool-next-chunk",
524 name: "MyTool",
525 params: { testParam: "test-param" }
526 })
527 yield* Queue.offer(providerQueue, { ...finishPart, reason: "length" })
528 yield* Queue.end(providerQueue)
529 const parts = yield* Fiber.join(fiber)
530
531 strictEqual(yield* Ref.get(calls), 0)
532 deepStrictEqual(parts.map((part) => part.type), ["tool-call", "tool-result", "finish"])
533
534 const toolResult = parts.find((part) => part.type === "tool-result")!
535 strictEqual(toolResult.isFailure, true)
536 deepStrictEqual<unknown>(toolResult.result, {
537 type: "execution-interrupted",
538 reason: `Tool call execution was interrupted because the response finished with reason "length"`
539 })
540 }))
541
542 it.effect("keeps results of handlers that completed before an incomplete finish", () =>
543 Effect.gen(function*() {
544 const parts: Array<Response.StreamPart<Toolkit.Tools<typeof MyToolkit>, "opaque">> = []
545 const toolCallObserved = yield* Latch.make()
546 const resultObserved = yield* Latch.make()
547 const providerQueue = yield* Queue.make<Response.StreamPartEncoded, Cause.Done>()
548 const handlers = MyToolkit.toLayer({
549 MyTool: () => Effect.succeed({ testSuccess: "test-success" })
550 })
551
552 const fiber = yield* LanguageModel.streamText({
553 prompt: [],
554 toolkit: MyToolkit
555 }).pipe(
556 Stream.runForEach((part) =>
557 Effect.sync(() => {
558 parts.push(part)
559 }).pipe(
560 Effect.andThen(
561 part.type === "tool-call"
562 ? toolCallObserved.open
563 : part.type === "tool-result"
564 ? resultObserved.open
565 : Effect.void
566 )
567 )
568 ),
569 TestUtils.withLanguageModel({
570 streamText: () => Stream.fromQueue(providerQueue)
571 }),
572 Effect.provide(handlers),
573 Effect.forkScoped
574 )
575
576 yield* Queue.offer(providerQueue, {
577 type: "tool-call",
578 id: "tool-completed",
579 name: "MyTool",
580 params: { testParam: "test-param" }
581 })
582 yield* toolCallObserved.await
583 yield* Queue.offer(providerQueue, { type: "text-delta", id: "text-1", delta: "more" })
584 yield* resultObserved.await
585
586 yield* Queue.offer(providerQueue, { ...finishPart, reason: "length" })
587 yield* Queue.end(providerQueue)
588 yield* Fiber.join(fiber)
589
590 // The completed handler keeps its real result and no synthesized
591 // failure result is added for the same call
592 deepStrictEqual(parts.map((part) => part.type), ["tool-call", "text-delta", "tool-result", "finish"])
593 const toolResult = parts.find((part) => part.type === "tool-result")!
594 strictEqual(toolResult.isFailure, false)
595 deepStrictEqual(toolResult.result, { testSuccess: "test-success" })
596 }))
597
598 it.effect("does not synthesize results for tool calls awaiting approval on an incomplete finish", () =>
599 Effect.gen(function*() {
600 const parts: Array<Response.StreamPart<Toolkit.Tools<typeof ApprovalToolkit>, "opaque">> = []
601 const toolCallObserved = yield* Latch.make()
602 const approvalObserved = yield* Latch.make()
603 const providerQueue = yield* Queue.make<Response.StreamPartEncoded, Cause.Done>()
604
605 const fiber = yield* LanguageModel.streamText({
606 prompt: [],
607 toolkit: ApprovalToolkit
608 }).pipe(
609 Stream.runForEach((part) =>
610 Effect.sync(() => {
611 parts.push(part)
612 }).pipe(
613 Effect.andThen(
614 part.type === "tool-call"
615 ? toolCallObserved.open
616 : part.type === "tool-approval-request"
617 ? approvalObserved.open
618 : Effect.void
619 )
620 )
621 ),
622 TestUtils.withLanguageModel({
623 streamText: () => Stream.fromQueue(providerQueue)
624 }),
625 Effect.provide(ApprovalToolkitLayer),
626 Effect.forkScoped
627 )
628
629 yield* Queue.offer(providerQueue, {
630 type: "tool-call",
631 id: "tool-awaiting-approval",
632 name: "ApprovalTool",
633 params: { action: "test-action" }
634 })
635 yield* toolCallObserved.await
636 yield* Queue.offer(providerQueue, { type: "text-delta", id: "text-1", delta: "more" })
637 yield* approvalObserved.await
638
639 yield* Queue.offer(providerQueue, { ...finishPart, reason: "length" })
640 yield* Queue.end(providerQueue)
641 yield* Fiber.join(fiber)
642
643 // The approval request already resolves the call for this turn
644 deepStrictEqual(
645 parts.map((part) => part.type),
646 ["tool-call", "text-delta", "tool-approval-request", "finish"]
647 )
648 }))
649
650 it.effect("validates encoded tool parameters when tool call resolution is disabled", () =>
651 Effect.gen(function*() {
652 const error = yield* LanguageModel.streamText({
653 prompt: [],
654 toolkit: TransformToolkit,
655 disableToolCallResolution: true
656 }).pipe(
657 Stream.runDrain,
658 TestUtils.withLanguageModel({
659 streamText: [{
660 type: "tool-call",
661 id: "tool-invalid-transform",
662 name: "TransformTool",
663 params: { invalid: true }
664 }]
665 }),
666 Effect.provide(TransformToolkitLayer),
667 Effect.flip
668 )
669
670 strictEqual(error.reason._tag, "InvalidOutputError")
671 }))
672
673 it.effect("preserves encoded tool parameters when tool call resolution is disabled", () =>
674 Effect.gen(function*() {
675 const parts = yield* LanguageModel.streamText({
676 prompt: [],
677 toolkit: TransformToolkit,
678 disableToolCallResolution: true
679 }).pipe(Stream.runCollect)
680 const toolCall = parts.find((part) => part.type === "tool-call")!
681
682 strictEqual(toolCall.params, "21")
683
684 const toolkit = yield* TransformToolkit
685 const results = yield* toolkit.handle(toolCall.name, toolCall.params).pipe(
686 Effect.flatMap(Stream.runCollect)
687 )
688
689 strictEqual(results[0].result, 42)
690 }).pipe(
691 TestUtils.withLanguageModel({
692 streamText: [{
693 type: "tool-call",
694 id: "tool-transform",
695 name: "TransformTool",
696 params: "21"
697 }]
698 }),
699 Effect.provide(TransformToolkitLayer)
700 ))
701
702 it.effect("preserves encoded tool parameters when tool call resolution is enabled", () =>
703 Effect.gen(function*() {
704 const parts = yield* LanguageModel.streamText({
705 prompt: [],
706 toolkit: TransformToolkit
707 }).pipe(Stream.runCollect)
708
709 const toolCall = parts.find((part) => part.type === "tool-call")!
710 strictEqual(toolCall.params, "21")
711
712 const toolResult = parts.find((part) => part.type === "tool-result")!
713 strictEqual(toolResult.isFailure, false)
714 strictEqual(toolResult.result, 42)
715 }).pipe(
716 TestUtils.withLanguageModel({
717 streamText: [
718 {
719 type: "tool-call",
720 id: "tool-transform",
721 name: "TransformTool",
722 params: "21"
723 },
724 finishPart
725 ]
726 }),
727 Effect.provide(TransformToolkitLayer)
728 ))
729
730 it.effect("validates provider-executed tool call parameters", () =>
731 Effect.gen(function*() {
732 const calls = yield* Ref.make(0)
733 const handlers = MyToolkit.toLayer({
734 MyTool: () =>
735 Ref.update(calls, (n) => n + 1).pipe(
736 Effect.as({ testSuccess: "test-success" })
737 )
738 })
739
740 const error = yield* LanguageModel.streamText({
741 prompt: [],
742 toolkit: MyToolkit
743 }).pipe(
744 Stream.runDrain,
745 TestUtils.withLanguageModel({
746 streamText: [
747 {
748 type: "tool-call",
749 id: "tool-provider-executed",
750 name: "MyTool",
751 providerExecuted: true,
752 params: { testParam: 123 }
753 } as any,
754 finishPart
755 ]
756 }),
757 Effect.provide(handlers),
758 Effect.flip
759 )
760
761 strictEqual(error._tag, "AiError")
762 strictEqual((error as AiError.AiError).reason._tag, "InvalidOutputError")
763 strictEqual(yield* Ref.get(calls), 0)
764 }))
765
766 it.effect("executes tool handlers once the stream moves past the tool call", () =>
767 Effect.gen(function*() {
768 const parts: Array<Response.StreamPart<Toolkit.Tools<typeof MyToolkit>, "opaque">> = []
769 const toolCallObserved = yield* Latch.make()
770 const handlerCalled = yield* Latch.make()
771 const providerQueue = yield* Queue.make<Response.StreamPartEncoded, Cause.Done>()
772 const calls = yield* Ref.make(0)
773 const handlers = MyToolkit.toLayer({
774 MyTool: () =>
775 Ref.update(calls, (n) => n + 1).pipe(
776 Effect.andThen(handlerCalled.open),
777 Effect.as({ testSuccess: "test-success" })
778 )
779 })
780
781 const fiber = yield* LanguageModel.streamText({
782 prompt: [],
783 toolkit: MyToolkit
784 }).pipe(
785 Stream.runForEach((part) =>
786 Effect.sync(() => {
787 parts.push(part)
788 }).pipe(
789 Effect.andThen(part.type === "tool-call" ? toolCallObserved.open : Effect.void)
790 )
791 ),
792 TestUtils.withLanguageModel({
793 streamText: () => Stream.fromQueue(providerQueue)
794 }),
795 Effect.provide(handlers),
796 Effect.forkScoped
797 )
798
799 yield* Queue.offer(providerQueue, {
800 type: "tool-call",
801 id: "tool-abc123",
802 name: "MyTool",
803 params: { testParam: "test-param" }
804 })
805 yield* toolCallObserved.await
806 yield* Queue.offer(providerQueue, { type: "text-delta", id: "text-1", delta: "more" })
807
808 // The handler runs while the provider stream is still open
809 yield* handlerCalled.await
810 strictEqual(yield* Ref.get(calls), 1)
811
812 yield* Queue.offer(providerQueue, finishPart)
813 yield* Queue.end(providerQueue)
814 yield* Fiber.join(fiber)
815
816 deepStrictEqual(parts.map((part) => part.type), ["tool-call", "text-delta", "tool-result", "finish"])
817 }))
818
819 it.effect("interrupts in-flight tool handlers when the provider stream fails", () =>
820 Effect.gen(function*() {
821 const toolCallObserved = yield* Latch.make()
822 const handlerStarted = yield* Latch.make()
823 const handlerInterrupted = yield* Latch.make()
824 const handlers = MyToolkit.toLayer({
825 MyTool: () =>
826 handlerStarted.open.pipe(
827 Effect.andThen(Effect.never),
828 Effect.onInterrupt(() => handlerInterrupted.open),
829 Effect.as({ testSuccess: "test-success" })
830 )
831 })
832 const providerQueue = yield* Queue.make<Response.StreamPartEncoded, Cause.Done>()
833 const providerError = AiError.make({
834 module: "LanguageModelTest",
835 method: "streamText",
836 reason: new AiError.InvalidRequestError({ description: "provider stream failed" })
837 })
838
839 const fiber = yield* LanguageModel.streamText({
840 prompt: [],
841 toolkit: MyToolkit
842 }).pipe(
843 Stream.runForEach((part) => part.type === "tool-call" ? toolCallObserved.open : Effect.void),
844 TestUtils.withLanguageModel({
845 streamText: () =>
846 Stream.fromQueue(providerQueue).pipe(
847 Stream.concat(Stream.fail(providerError))
848 )
849 }),
850 Effect.provide(handlers),
851 Effect.forkScoped
852 )
853
854 yield* Queue.offer(providerQueue, {
855 type: "tool-call",
856 id: "tool-before-failure",
857 name: "MyTool",
858 params: { testParam: "test-param" }
859 })
860 yield* toolCallObserved.await
861 yield* Queue.offer(providerQueue, { type: "text-delta", id: "text-1", delta: "more" })
862 yield* handlerStarted.await
863 yield* Queue.end(providerQueue)
864
865 const error = yield* Fiber.join(fiber).pipe(Effect.flip)
866 strictEqual(error, providerError)
867 yield* handlerInterrupted.await
868 }))
869
870 it.effect("does not execute tool handlers when response content is malformed", () =>
871 Effect.gen(function*() {
872 const calls = yield* Ref.make(0)
873 const handlers = MyToolkit.toLayer({
874 MyTool: () =>
875 Ref.update(calls, (n) => n + 1).pipe(
876 Effect.as({ testSuccess: "test-success" })
877 )
878 })
879
880 const error = yield* LanguageModel.streamText({
881 prompt: [],
882 toolkit: MyToolkit
883 }).pipe(
884 Stream.runDrain,
885 TestUtils.withLanguageModel({
886 streamText: [
887 {
888 type: "tool-call",
889 id: "tool-before-invalid-part",
890 name: "MyTool",
891 params: { testParam: "test-param" }
892 },
893 { type: "text", text: 123 } as any,
894 finishPart
895 ]
896 }),
897 Effect.provide(handlers),
898 Effect.flip
899 )
900
901 strictEqual(error._tag, "AiError")
902 strictEqual((error as AiError.AiError).reason._tag, "InvalidOutputError")
903 strictEqual(yield* Ref.get(calls), 0)
904 }))
905
906 it.effect("emits a failed tool result and keeps streaming when tool params are invalid and failure mode is return", () =>
907 Effect.gen(function*() {
908 const calls = yield* Ref.make(0)
909 const handlers = ReturnModeToolkit.toLayer({
910 ReturnModeTool: () =>
911 Ref.update(calls, (n) => n + 1).pipe(
912 Effect.as({ testSuccess: "test-success" })
913 )
914 })
915
916 const parts = yield* LanguageModel.streamText({
917 prompt: [],
918 toolkit: ReturnModeToolkit
919 }).pipe(
920 Stream.runCollect,
921 TestUtils.withLanguageModel({
922 streamText: [
923 {
924 type: "tool-call",
925 id: "tool-invalid-params",
926 name: "ReturnModeTool",
927 params: { testParam: 123 }
928 } as any,
929 finishPart
930 ]
931 }),
932 Effect.provide(handlers)
933 )
934
935 const toolResults = parts.filter((part) => part.type === "tool-result")
936 strictEqual(toolResults.length, 1)
937 strictEqual(toolResults[0].isFailure, true)
938 const result = toolResults[0].result as AiError.AiError
939 strictEqual(result._tag, "AiError")
940 strictEqual(result.reason._tag, "ToolParameterValidationError")
941 strictEqual(parts.some((part) => part.type === "finish"), true)
942 strictEqual(yield* Ref.get(calls), 0)
943 }))
944
945 it.effect("fails the stream when tool params are invalid and failure mode is error", () =>
946 Effect.gen(function*() {
947 const calls = yield* Ref.make(0)
948 const handlers = MyToolkit.toLayer({
949 MyTool: () =>
950 Ref.update(calls, (n) => n + 1).pipe(
951 Effect.as({ testSuccess: "test-success" })
952 )
953 })
954
955 const error = yield* LanguageModel.streamText({
956 prompt: [],
957 toolkit: MyToolkit
958 }).pipe(
959 Stream.runDrain,
960 TestUtils.withLanguageModel({
961 streamText: [
962 {
963 type: "tool-call",
964 id: "tool-invalid-params",
965 name: "MyTool",
966 params: { testParam: 123 }
967 } as any,
968 finishPart
969 ]
970 }),
971 Effect.provide(handlers),
972 Effect.flip
973 )
974
975 strictEqual(error._tag, "AiError")
976 strictEqual((error as AiError.AiError).reason._tag, "ToolParameterValidationError")
977 strictEqual(yield* Ref.get(calls), 0)
978 }))
979
980 it.effect("emits finish after resolved tool results", () =>
981 Effect.gen(function*() {
982 const parts: Array<Response.StreamPart<Toolkit.Tools<typeof MyToolkit>, "opaque">> = []
983 const latch = yield* Latch.make()
984
985 const fiber = yield* LanguageModel.streamText({
986 prompt: [],
987 toolkit: MyToolkit
988 }).pipe(
989 Stream.runForEach((part) =>
990 Effect.andThen(
991 latch.open,
992 Effect.sync(() => {
993 parts.push(part)
994 })
995 )
996 ),
997 TestUtils.withLanguageModel({
998 streamText: [
999 {
1000 type: "tool-call",
1001 id: "tool-finish-order",
1002 name: "MyTool",
1003 params: { testParam: "test-param" }
1004 },
1005 finishPart
1006 ]
1007 }),
1008 Effect.provide(MyToolkitLayer),
1009 Effect.forkScoped
1010 )
1011
1012 yield* latch.await
1013
1014 strictEqual(parts[0]?.type, "tool-call")
1015 strictEqual(parts.some((part) => part.type === "finish"), false)
1016
1017 yield* TestClock.adjust("10 seconds")
1018 yield* Fiber.join(fiber)
1019
1020 strictEqual(parts.length, 3)
1021 strictEqual(parts[0]?.type, "tool-call")
1022 strictEqual(parts[1]?.type, "tool-result")
1023 strictEqual(parts[2]?.type, "finish")
1024 }))
1025
1026 it.effect("runs tool handlers sequentially with concurrency: 1", () =>
1027 Effect.gen(function*() {
1028 const active = yield* Ref.make(0)
1029 const maxActive = yield* Ref.make(0)
1030 const started = yield* Latch.make()
1031 const release = yield* Latch.make()
1032
1033 const handlers = MyToolkit.toLayer({
1034 MyTool: () =>
1035 Effect.gen(function*() {
1036 const current = yield* Ref.updateAndGet(active, (n) => n + 1)
1037 yield* Ref.update(maxActive, (n) => Math.max(n, current))
1038 yield* started.open
1039 yield* release.await
1040 return { testSuccess: "test-success" }
1041 }).pipe(Effect.ensuring(Ref.update(active, (n) => n - 1)))
1042 })
1043
1044 const fiber = yield* LanguageModel.streamText({
1045 prompt: [],
1046 toolkit: MyToolkit,
1047 concurrency: 1
1048 }).pipe(
1049 Stream.runDrain,
1050 TestUtils.withLanguageModel({
1051 streamText: [
1052 {
1053 type: "tool-call",
1054 id: "tool-1",
1055 name: "MyTool",
1056 params: { testParam: "test-1" }
1057 },
1058 {
1059 type: "tool-call",
1060 id: "tool-2",
1061 name: "MyTool",
1062 params: { testParam: "test-2" }
1063 },
1064 {
1065 type: "tool-call",
1066 id: "tool-3",
1067 name: "MyTool",
1068 params: { testParam: "test-3" }
1069 }
1070 ]
1071 }),
1072 Effect.provide(handlers),
1073 Effect.forkScoped
1074 )
1075
1076 yield* started.await
1077 strictEqual(yield* Ref.get(active), 1)
1078 strictEqual(yield* Ref.get(maxActive), 1)
1079
1080 yield* release.open
1081 yield* Fiber.join(fiber)
1082
1083 strictEqual(yield* Ref.get(active), 0)
1084 strictEqual(yield* Ref.get(maxActive), 1)
1085 }))
1086
1087 it.effect("allows tool handler overlap up to a bounded concurrency", () =>
1088 Effect.gen(function*() {
1089 const active = yield* Ref.make(0)
1090 const maxActive = yield* Ref.make(0)
1091 const twoStarted = yield* Latch.make()
1092 const release = yield* Latch.make()
1093
1094 const handlers = MyToolkit.toLayer({
1095 MyTool: () =>
1096 Effect.gen(function*() {
1097 const current = yield* Ref.updateAndGet(active, (n) => n + 1)
1098 yield* Ref.update(maxActive, (n) => Math.max(n, current))
1099 if (current === 2) {
1100 yield* twoStarted.open
1101 }
1102 yield* release.await
1103 return { testSuccess: "test-success" }
1104 }).pipe(Effect.ensuring(Ref.update(active, (n) => n - 1)))
1105 })
1106
1107 const fiber = yield* LanguageModel.streamText({
1108 prompt: [],
1109 toolkit: MyToolkit,
1110 concurrency: 2
1111 }).pipe(
1112 Stream.runDrain,
1113 TestUtils.withLanguageModel({
1114 streamText: [
1115 {
1116 type: "tool-call",
1117 id: "tool-1",
1118 name: "MyTool",
1119 params: { testParam: "test-1" }
1120 },
1121 {
1122 type: "tool-call",
1123 id: "tool-2",
1124 name: "MyTool",
1125 params: { testParam: "test-2" }
1126 },
1127 {
1128 type: "tool-call",
1129 id: "tool-3",
1130 name: "MyTool",
1131 params: { testParam: "test-3" }
1132 }
1133 ]
1134 }),
1135 Effect.provide(handlers),
1136 Effect.forkScoped
1137 )
1138
1139 yield* twoStarted.await
1140 strictEqual(yield* Ref.get(active), 2)
1141 strictEqual(yield* Ref.get(maxActive), 2)
1142
1143 yield* release.open
1144 yield* Fiber.join(fiber)
1145
1146 strictEqual(yield* Ref.get(active), 0)
1147 strictEqual(yield* Ref.get(maxActive), 2)
1148 }))
1149
1150 it.effect("provides tool call IDs to concurrent identical tool handlers", () =>
1151 Effect.gen(function*() {
1152 const toolCallIds = yield* Ref.make<Array<string>>([])
1153 const twoStarted = yield* Latch.make()
1154 const release = yield* Latch.make()
1155
1156 const handlers = MyToolkit.toLayer({
1157 MyTool: (_, context) =>
1158 Effect.gen(function*() {
1159 const toolCallId = context.toolCallId
1160 assertDefined(toolCallId)
1161 const ids = yield* Ref.updateAndGet(toolCallIds, (ids) => [...ids, toolCallId])
1162 if (ids.length === 2) {
1163 yield* twoStarted.open
1164 }
1165 yield* release.await
1166 return { testSuccess: "test-success" }
1167 })
1168 })
1169
1170 const fiber = yield* LanguageModel.streamText({
1171 prompt: [],
1172 toolkit: MyToolkit
1173 }).pipe(
1174 Stream.runDrain,
1175 TestUtils.withLanguageModel({
1176 streamText: [
1177 {
1178 type: "tool-call",
1179 id: "tool-1",
1180 name: "MyTool",
1181 params: { testParam: "identical" }
1182 },
1183 {
1184 type: "tool-call",
1185 id: "tool-2",
1186 name: "MyTool",
1187 params: { testParam: "identical" }
1188 }
1189 ]
1190 }),
1191 Effect.provide(handlers),
1192 Effect.forkScoped
1193 )
1194
1195 yield* twoStarted.await
1196 deepStrictEqual((yield* Ref.get(toolCallIds)).sort(), ["tool-1", "tool-2"])
1197
1198 yield* release.open
1199 yield* Fiber.join(fiber)
1200 }))
1201
1202 it.effect("bounds needsApproval evaluation with the tool handler concurrency", () =>
1203 Effect.gen(function*() {
1204 const active = yield* Ref.make(0)
1205 const maxActive = yield* Ref.make(0)
1206 const started = yield* Latch.make()
1207 const release = yield* Latch.make()
1208
1209 const tool = Tool.make("ApprovalConcurrencyTool", {
1210 parameters: Schema.Struct({ input: Schema.String }),
1211 success: Schema.Struct({ output: Schema.String }),
1212 needsApproval: () =>
1213 Effect.gen(function*() {
1214 const current = yield* Ref.updateAndGet(active, (n) => n + 1)
1215 yield* Ref.update(maxActive, (n) => Math.max(n, current))
1216 yield* started.open
1217 yield* release.await
1218 return false
1219 }).pipe(Effect.ensuring(Ref.update(active, (n) => n - 1)))
1220 })
1221 const toolkit = Toolkit.make(tool)
1222 const handlers = toolkit.toLayer({
1223 ApprovalConcurrencyTool: () => Effect.succeed({ output: "done" })
1224 })
1225
1226 const fiber = yield* LanguageModel.streamText({
1227 prompt: [],
1228 toolkit,
1229 concurrency: 1
1230 }).pipe(
1231 Stream.runDrain,
1232 TestUtils.withLanguageModel({
1233 streamText: [
1234 {
1235 type: "tool-call",
1236 id: "tool-1",
1237 name: "ApprovalConcurrencyTool",
1238 params: { input: "test-1" }
1239 },
1240 {
1241 type: "tool-call",
1242 id: "tool-2",
1243 name: "ApprovalConcurrencyTool",
1244 params: { input: "test-2" }
1245 }
1246 ]
1247 }),
1248 Effect.provide(handlers),
1249 Effect.forkScoped
1250 )
1251
1252 yield* started.await
1253 strictEqual(yield* Ref.get(active), 1)
1254 strictEqual(yield* Ref.get(maxActive), 1)
1255
1256 yield* release.open
1257 yield* Fiber.join(fiber)
1258
1259 strictEqual(yield* Ref.get(active), 0)
1260 strictEqual(yield* Ref.get(maxActive), 1)
1261 }))
1262 })
1263
1264 describe("generateObject", () => {
1265 it.effect("includes full generated text in StructuredOutputError", () =>
1266 Effect.gen(function*() {
1267 const error = yield* LanguageModel.generateObject({
1268 prompt: [],
1269 schema: Schema.Struct({ count: Schema.Number })
1270 }).pipe(
1271 TestUtils.withLanguageModel({
1272 generateText: [{
1273 type: "text",
1274 text: "{\"count\":\"oops\"}"
1275 }]
1276 }),
1277 Effect.flip
1278 )
1279
1280 strictEqual(error.reason._tag, "StructuredOutputError")
1281 if (error.reason._tag === "StructuredOutputError") {
1282 strictEqual(error.reason.responseText, "{\"count\":\"oops\"}")
1283 }
1284 }))
1285
1286 it("resolves the canonical top-level $ref for class schemas in defaultCodecTransformer", () => {
1287 class Person extends Schema.Class<Person>("Person")({
1288 name: Schema.String
1289 }) {}
1290
1291 const transformed = LanguageModel.defaultCodecTransformer(Person)
1292
1293 deepStrictEqual(transformed.jsonSchema, {
1294 type: "object",
1295 properties: {
1296 name: {
1297 type: "string"
1298 }
1299 },
1300 required: ["name"],
1301 additionalProperties: true,
1302 $defs: {
1303 "PersonEncoded": {
1304 type: "object",
1305 properties: {
1306 name: {
1307 type: "string"
1308 }
1309 },
1310 required: ["name"],
1311 additionalProperties: true
1312 }
1313 }
1314 })
1315 })
1316 })
1317
1318 describe("provider options", () => {
1319 it.effect("initialize incremental fields as undefined in generateText", () =>
1320 Effect.gen(function*() {
1321 let capturedOptions: LanguageModel.ProviderOptions | undefined
1322
1323 yield* LanguageModel.generateText({
1324 prompt: []
1325 }).pipe(
1326 TestUtils.withLanguageModel({
1327 generateText: (options) => {
1328 capturedOptions = options
1329 return Effect.succeed([finishPart])
1330 }
1331 })
1332 )
1333
1334 assertDefined(capturedOptions)
1335 strictEqual(capturedOptions.previousResponseId, undefined)
1336 strictEqual(capturedOptions.incrementalPrompt, undefined)
1337 }))
1338
1339 it.effect("initialize incremental fields as undefined in generateObject", () =>
1340 Effect.gen(function*() {
1341 let capturedOptions: LanguageModel.ProviderOptions | undefined
1342
1343 yield* LanguageModel.generateObject({
1344 prompt: [],
1345 schema: Schema.Struct({ count: Schema.Number })
1346 }).pipe(
1347 TestUtils.withLanguageModel({
1348 generateText: (options) => {
1349 capturedOptions = options
1350 return Effect.succeed([
1351 {
1352 type: "text",
1353 text: "{\"count\":1}"
1354 },
1355 finishPart
1356 ])
1357 }
1358 })
1359 )
1360
1361 assertDefined(capturedOptions)
1362 strictEqual(capturedOptions.previousResponseId, undefined)
1363 strictEqual(capturedOptions.incrementalPrompt, undefined)
1364 }))
1365
1366 it.effect("initialize incremental fields as undefined in streamText", () =>
1367 Effect.gen(function*() {
1368 let capturedOptions: LanguageModel.ProviderOptions | undefined
1369
1370 yield* LanguageModel.streamText({
1371 prompt: []
1372 }).pipe(
1373 Stream.runDrain,
1374 TestUtils.withLanguageModel({
1375 streamText: (options) => {
1376 capturedOptions = options
1377 return [finishPart]
1378 }
1379 })
1380 )
1381
1382 assertDefined(capturedOptions)
1383 strictEqual(capturedOptions.previousResponseId, undefined)
1384 strictEqual(capturedOptions.incrementalPrompt, undefined)
1385 }))
1386
1387 it.effect("falls back to full prompt in generateText when incremental request fails", () =>
1388 Effect.gen(function*() {
1389 const fullPrompt = Prompt.make([
1390 Prompt.systemMessage({ content: "system" }),
1391 Prompt.userMessage({ content: [Prompt.textPart({ text: "user" })] }),
1392 Prompt.assistantMessage({ content: [Prompt.textPart({ text: "assistant" })] }),
1393 Prompt.userMessage({ content: [Prompt.textPart({ text: "next" })] })
1394 ])
1395
1396 const incrementalPrompt = Prompt.make([
1397 Prompt.userMessage({ content: [Prompt.textPart({ text: "next" })] })
1398 ])
1399
1400 const calls: Array<LanguageModel.ProviderOptions> = []
1401
1402 yield* LanguageModel.generateText({
1403 prompt: fullPrompt
1404 }).pipe(
1405 Effect.provideServiceEffect(
1406 LanguageModel.LanguageModel,
1407 LanguageModel.make({
1408 generateText: (options) => {
1409 calls.push(options)
1410 if (calls.length === 1) {
1411 ;(options as any).prompt = options.incrementalPrompt ?? options.prompt
1412 return Effect.fail(AiError.make({
1413 module: "LanguageModelTest",
1414 method: "generateText",
1415 reason: new AiError.InvalidRequestError({
1416 description: "invalid previous response id"
1417 })
1418 }))
1419 }
1420 return Effect.succeed([finishPart])
1421 },
1422 streamText: () => Stream.empty
1423 })
1424 ),
1425 Effect.provideService(ResponseIdTracker.ResponseIdTracker, {
1426 clearUnsafe() {},
1427 markParts() {},
1428 prepareUnsafe: () =>
1429 Option.some({
1430 previousResponseId: "resp_prev",
1431 prompt: incrementalPrompt
1432 })
1433 })
1434 )
1435
1436 strictEqual(calls.length, 2)
1437 strictEqual(calls[0]!.previousResponseId, "resp_prev")
1438 strictEqual(calls[0]!.incrementalPrompt, incrementalPrompt)
1439 strictEqual(calls[1]!.previousResponseId, undefined)
1440 strictEqual(calls[1]!.incrementalPrompt, undefined)
1441 deepStrictEqual(calls[1]!.prompt, fullPrompt)
1442 }))
1443
1444 it.effect("falls back to full prompt in streamText when incremental request fails", () =>
1445 Effect.gen(function*() {
1446 const fullPrompt = Prompt.make([
1447 Prompt.systemMessage({ content: "system" }),
1448 Prompt.userMessage({ content: [Prompt.textPart({ text: "user" })] }),
1449 Prompt.assistantMessage({ content: [Prompt.textPart({ text: "assistant" })] }),
1450 Prompt.userMessage({ content: [Prompt.textPart({ text: "next" })] })
1451 ])
1452
1453 const incrementalPrompt = Prompt.make([
1454 Prompt.userMessage({ content: [Prompt.textPart({ text: "next" })] })
1455 ])
1456
1457 const calls: Array<LanguageModel.ProviderOptions> = []
1458
1459 yield* LanguageModel.streamText({
1460 prompt: fullPrompt
1461 }).pipe(
1462 Stream.runDrain,
1463 Effect.provideServiceEffect(
1464 LanguageModel.LanguageModel,
1465 LanguageModel.make({
1466 generateText: () => Effect.succeed([finishPart]),
1467 streamText: (options) => {
1468 calls.push(options)
1469 if (calls.length === 1) {
1470 ;(options as any).prompt = options.incrementalPrompt ?? options.prompt
1471 return Stream.fail(AiError.make({
1472 module: "LanguageModelTest",
1473 method: "streamText",
1474 reason: new AiError.InvalidRequestError({
1475 description: "invalid previous response id"
1476 })
1477 }))
1478 }
1479 return Stream.fromIterable([finishPart])
1480 }
1481 })
1482 ),
1483 Effect.provideService(ResponseIdTracker.ResponseIdTracker, {
1484 clearUnsafe() {},
1485 markParts() {},
1486 prepareUnsafe: () =>
1487 Option.some({
1488 previousResponseId: "resp_prev",
1489 prompt: incrementalPrompt
1490 })
1491 })
1492 )
1493
1494 strictEqual(calls.length, 2)
1495 strictEqual(calls[0]!.previousResponseId, "resp_prev")
1496 strictEqual(calls[0]!.incrementalPrompt, incrementalPrompt)
1497 strictEqual(calls[1]!.previousResponseId, undefined)
1498 strictEqual(calls[1]!.incrementalPrompt, undefined)
1499 deepStrictEqual(calls[1]!.prompt, fullPrompt)
1500 }))
1501
1502 it.effect("does not retry an incremental stream after emitting a tool call", () =>
1503 Effect.gen(function*() {
1504 const prompt = Prompt.make([
1505 Prompt.userMessage({ content: [Prompt.textPart({ text: "full prompt" })] })
1506 ])
1507 const incrementalPrompt = Prompt.make([
1508 Prompt.userMessage({ content: [Prompt.textPart({ text: "incremental prompt" })] })
1509 ])
1510 const handlerCalls = yield* Ref.make(0)
1511 const handlers = MyToolkit.toLayer({
1512 MyTool: () =>
1513 Ref.update(handlerCalls, (n) => n + 1).pipe(
1514 Effect.as({ testSuccess: "test-success" })
1515 )
1516 })
1517 let providerCalls = 0
1518 const providerError = AiError.make({
1519 module: "LanguageModelTest",
1520 method: "streamText",
1521 reason: new AiError.InvalidRequestError({ description: "incremental stream failed" })
1522 })
1523
1524 yield* LanguageModel.streamText({ prompt, toolkit: MyToolkit }).pipe(
1525 Stream.runDrain,
1526 TestUtils.withLanguageModel({
1527 streamText: () => {
1528 providerCalls++
1529 return Stream.succeed<Response.StreamPartEncoded>({
1530 type: "tool-call",
1531 id: "tool-before-incremental-failure",
1532 name: "MyTool",
1533 params: { testParam: "test-param" }
1534 }).pipe(Stream.concat(Stream.fail(providerError)))
1535 }
1536 }),
1537 Effect.provideService(ResponseIdTracker.ResponseIdTracker, {
1538 clearUnsafe() {},
1539 markParts() {},
1540 prepareUnsafe: () =>
1541 Option.some({
1542 previousResponseId: "resp_prev",
1543 prompt: incrementalPrompt
1544 })
1545 }),
1546 Effect.provide(handlers),
1547 Effect.flip
1548 )
1549
1550 strictEqual(providerCalls, 1)
1551 strictEqual(yield* Ref.get(handlerCalls), 0)
1552 }))
1553
1554 it.effect("uses tracker prepareUnsafe and markParts in generateText without toolkit", () =>
1555 Effect.gen(function*() {
1556 let capturedOptions: LanguageModel.ProviderOptions | undefined
1557 let preparedPrompt: LanguageModel.ProviderOptions["prompt"] | undefined
1558 let markedParts: ReadonlyArray<object> | undefined
1559 let markedResponseId: string | undefined
1560
1561 const incrementalPrompt = Prompt.make([
1562 Prompt.userMessage({ content: [Prompt.textPart({ text: "incremental" })] })
1563 ])
1564
1565 yield* LanguageModel.generateText({
1566 prompt: [Prompt.userMessage({ content: [Prompt.textPart({ text: "hello" })] })]
1567 }).pipe(
1568 Effect.provideServiceEffect(
1569 LanguageModel.LanguageModel,
1570 LanguageModel.make({
1571 generateText: (options) => {
1572 capturedOptions = options
1573 return Effect.succeed([
1574 {
1575 type: "response-metadata",
1576 id: "resp_next",
1577 modelId: undefined,
1578 timestamp: undefined,
1579 request: undefined
1580 },
1581 finishPart
1582 ])
1583 },
1584 streamText: () => Stream.empty
1585 })
1586 ),
1587 Effect.provideService(ResponseIdTracker.ResponseIdTracker, {
1588 clearUnsafe() {},
1589 markParts: (parts, responseId) => {
1590 markedParts = parts
1591 markedResponseId = responseId
1592 },
1593 prepareUnsafe: (prompt) => {
1594 preparedPrompt = prompt
1595 return Option.some({
1596 previousResponseId: "resp_prev",
1597 prompt: incrementalPrompt
1598 })
1599 }
1600 })
1601 )
1602
1603 assertDefined(capturedOptions)
1604 assertDefined(preparedPrompt)
1605 strictEqual(preparedPrompt, capturedOptions.prompt)
1606 strictEqual(capturedOptions.previousResponseId, "resp_prev")
1607 strictEqual(capturedOptions.incrementalPrompt, incrementalPrompt)
1608 assertDefined(markedParts)
1609 strictEqual(markedParts, capturedOptions.prompt.content)
1610 strictEqual(markedResponseId, "resp_next")
1611 }))
1612
1613 it.effect("uses tracker prepareUnsafe and markParts in generateText with empty toolkit", () =>
1614 Effect.gen(function*() {
1615 let capturedOptions: LanguageModel.ProviderOptions | undefined
1616 let prepareCalls = 0
1617 let markCalls = 0
1618
1619 yield* LanguageModel.generateText({
1620 prompt: [Prompt.userMessage({ content: [Prompt.textPart({ text: "hello" })] })],
1621 toolkit: Toolkit.empty
1622 }).pipe(
1623 Effect.provideServiceEffect(
1624 LanguageModel.LanguageModel,
1625 LanguageModel.make({
1626 generateText: (options) => {
1627 capturedOptions = options
1628 return Effect.succeed([
1629 {
1630 type: "response-metadata",
1631 id: "resp_next",
1632 modelId: undefined,
1633 timestamp: undefined,
1634 request: undefined
1635 },
1636 finishPart
1637 ])
1638 },
1639 streamText: () => Stream.empty
1640 })
1641 ),
1642 Effect.provideService(ResponseIdTracker.ResponseIdTracker, {
1643 clearUnsafe() {},
1644 markParts: () => {
1645 markCalls++
1646 },
1647 prepareUnsafe: () => {
1648 prepareCalls++
1649 return Option.some({
1650 previousResponseId: "resp_prev",
1651 prompt: Prompt.make([])
1652 })
1653 }
1654 })
1655 )
1656
1657 assertDefined(capturedOptions)
1658 strictEqual(capturedOptions.previousResponseId, "resp_prev")
1659 strictEqual(prepareCalls, 1)
1660 strictEqual(markCalls, 1)
1661 }))
1662
1663 it.effect("calls tracker.prepareUnsafe after stripping resolved approvals in toolkit flow", () =>
1664 Effect.gen(function*() {
1665 const toolCallId = "call-tracker"
1666 const approvalId = "approval-tracker"
1667
1668 let preparedPrompt: LanguageModel.ProviderOptions["prompt"] | undefined
1669 let markedParts: ReadonlyArray<object> | undefined
1670
1671 const prompt: Array<Prompt.Message> = [
1672 Prompt.assistantMessage({
1673 content: [
1674 Prompt.makePart("tool-call", {
1675 id: toolCallId,
1676 name: "ApprovalTool",
1677 params: { action: "delete" },
1678 providerExecuted: false
1679 }),
1680 Prompt.makePart("tool-approval-request", {
1681 approvalId,
1682 toolCallId
1683 })
1684 ]
1685 }),
1686 Prompt.toolMessage({
1687 content: [
1688 Prompt.toolApprovalResponsePart({
1689 approvalId,
1690 approved: true
1691 }),
1692 Prompt.toolResultPart({
1693 id: toolCallId,
1694 name: "ApprovalTool",
1695 result: { result: "approved-result" },
1696 isFailure: false,
1697 providerExecuted: false
1698 })
1699 ]
1700 }),
1701 Prompt.userMessage({ content: [Prompt.textPart({ text: "continue" })] })
1702 ]
1703
1704 yield* LanguageModel.generateText({
1705 prompt,
1706 toolkit: ApprovalToolkit
1707 }).pipe(
1708 Effect.provideServiceEffect(
1709 LanguageModel.LanguageModel,
1710 LanguageModel.make({
1711 generateText: () =>
1712 Effect.succeed([
1713 {
1714 type: "response-metadata",
1715 id: "resp_next",
1716 modelId: undefined,
1717 timestamp: undefined,
1718 request: undefined
1719 },
1720 finishPart
1721 ]),
1722 streamText: () => Stream.empty
1723 })
1724 ),
1725 Effect.provideService(ResponseIdTracker.ResponseIdTracker, {
1726 clearUnsafe() {},
1727 markParts(parts) {
1728 markedParts = parts
1729 },
1730 prepareUnsafe(prompt) {
1731 preparedPrompt = prompt
1732 return Option.none()
1733 }
1734 }),
1735 Effect.provide(ApprovalToolkitLayer)
1736 )
1737
1738 assertDefined(preparedPrompt)
1739 for (const msg of preparedPrompt.content) {
1740 if (msg.role === "assistant") {
1741 strictEqual(msg.content.filter((p) => p.type === "tool-approval-request").length, 0)
1742 }
1743 if (msg.role === "tool") {
1744 strictEqual(msg.content.filter((p) => p.type === "tool-approval-response").length, 0)
1745 }
1746 }
1747 assertDefined(markedParts)
1748 strictEqual(markedParts, preparedPrompt.content)
1749 }))
1750
1751 it.effect("uses tracker prepareUnsafe and markParts in streamText without toolkit", () =>
1752 Effect.gen(function*() {
1753 let capturedOptions: LanguageModel.ProviderOptions | undefined
1754 let preparedPrompt: LanguageModel.ProviderOptions["prompt"] | undefined
1755 let markedParts: ReadonlyArray<object> | undefined
1756 let markedResponseId: string | undefined
1757
1758 const incrementalPrompt = Prompt.make([
1759 Prompt.userMessage({ content: [Prompt.textPart({ text: "incremental" })] })
1760 ])
1761
1762 yield* LanguageModel.streamText({
1763 prompt: [Prompt.userMessage({ content: [Prompt.textPart({ text: "hello" })] })]
1764 }).pipe(
1765 Stream.runDrain,
1766 Effect.provideServiceEffect(
1767 LanguageModel.LanguageModel,
1768 LanguageModel.make({
1769 generateText: () => Effect.succeed([]),
1770 streamText: (options) => {
1771 capturedOptions = options
1772 return Stream.fromIterable([
1773 {
1774 type: "response-metadata",
1775 id: "resp_next",
1776 modelId: undefined,
1777 timestamp: undefined,
1778 request: undefined
1779 },
1780 finishPart
1781 ])
1782 }
1783 })
1784 ),
1785 Effect.provideService(ResponseIdTracker.ResponseIdTracker, {
1786 clearUnsafe() {},
1787 markParts: (parts, responseId) => {
1788 markedParts = parts
1789 markedResponseId = responseId
1790 },
1791 prepareUnsafe: (prompt) => {
1792 preparedPrompt = prompt
1793 return Option.some({
1794 previousResponseId: "resp_prev",
1795 prompt: incrementalPrompt
1796 })
1797 }
1798 })
1799 )
1800
1801 assertDefined(capturedOptions)
1802 assertDefined(preparedPrompt)
1803 strictEqual(preparedPrompt, capturedOptions.prompt)
1804 strictEqual(capturedOptions.previousResponseId, "resp_prev")
1805 strictEqual(capturedOptions.incrementalPrompt, incrementalPrompt)
1806 assertDefined(markedParts)
1807 strictEqual(markedParts, capturedOptions.prompt.content)
1808 strictEqual(markedResponseId, "resp_next")
1809 }))
1810
1811 it.effect("uses tracker prepareUnsafe and markParts in streamText with empty toolkit", () =>
1812 Effect.gen(function*() {
1813 let capturedOptions: LanguageModel.ProviderOptions | undefined
1814 let preparedPrompt: LanguageModel.ProviderOptions["prompt"] | undefined
1815 let markedParts: ReadonlyArray<object> | undefined
1816 let markedResponseId: string | undefined
1817
1818 const incrementalPrompt = Prompt.make([
1819 Prompt.userMessage({ content: [Prompt.textPart({ text: "incremental" })] })
1820 ])
1821
1822 yield* LanguageModel.streamText({
1823 prompt: [Prompt.userMessage({ content: [Prompt.textPart({ text: "hello" })] })],
1824 toolkit: Toolkit.empty
1825 }).pipe(
1826 Stream.runDrain,
1827 Effect.provideServiceEffect(
1828 LanguageModel.LanguageModel,
1829 LanguageModel.make({
1830 generateText: () => Effect.succeed([]),
1831 streamText: (options) => {
1832 capturedOptions = options
1833 return Stream.fromIterable([
1834 {
1835 type: "response-metadata",
1836 id: "resp_next",
1837 modelId: undefined,
1838 timestamp: undefined,
1839 request: undefined
1840 },
1841 finishPart
1842 ])
1843 }
1844 })
1845 ),
1846 Effect.provideService(ResponseIdTracker.ResponseIdTracker, {
1847 clearUnsafe() {},
1848 markParts: (parts, responseId) => {
1849 markedParts = parts
1850 markedResponseId = responseId
1851 },
1852 prepareUnsafe: (prompt) => {
1853 preparedPrompt = prompt
1854 return Option.some({
1855 previousResponseId: "resp_prev",
1856 prompt: incrementalPrompt
1857 })
1858 }
1859 })
1860 )
1861
1862 assertDefined(capturedOptions)
1863 assertDefined(preparedPrompt)
1864 strictEqual(preparedPrompt, capturedOptions.prompt)
1865 strictEqual(capturedOptions.previousResponseId, "resp_prev")
1866 strictEqual(capturedOptions.incrementalPrompt, incrementalPrompt)
1867 assertDefined(markedParts)
1868 strictEqual(markedParts, capturedOptions.prompt.content)
1869 strictEqual(markedResponseId, "resp_next")
1870 }))
1871
1872 it.effect("calls tracker.prepareUnsafe after stripping resolved approvals in streamText toolkit flow", () =>
1873 Effect.gen(function*() {
1874 const toolCallId = "call-tracker-stream"
1875 const approvalId = "approval-tracker-stream"
1876
1877 let preparedPrompt: LanguageModel.ProviderOptions["prompt"] | undefined
1878 let markedParts: ReadonlyArray<object> | undefined
1879
1880 const prompt: Array<Prompt.Message> = [
1881 Prompt.assistantMessage({
1882 content: [
1883 Prompt.makePart("tool-call", {
1884 id: toolCallId,
1885 name: "ApprovalTool",
1886 params: { action: "delete" },
1887 providerExecuted: false
1888 }),
1889 Prompt.makePart("tool-approval-request", {
1890 approvalId,
1891 toolCallId
1892 })
1893 ]
1894 }),
1895 Prompt.toolMessage({
1896 content: [
1897 Prompt.toolApprovalResponsePart({
1898 approvalId,
1899 approved: true
1900 }),
1901 Prompt.toolResultPart({
1902 id: toolCallId,
1903 name: "ApprovalTool",
1904 result: { result: "approved-result" },
1905 isFailure: false,
1906 providerExecuted: false
1907 })
1908 ]
1909 }),
1910 Prompt.userMessage({ content: [Prompt.textPart({ text: "continue" })] })
1911 ]
1912
1913 yield* LanguageModel.streamText({
1914 prompt,
1915 toolkit: ApprovalToolkit
1916 }).pipe(
1917 Stream.runDrain,
1918 Effect.provideServiceEffect(
1919 LanguageModel.LanguageModel,
1920 LanguageModel.make({
1921 generateText: () => Effect.succeed([]),
1922 streamText: () =>
1923 Stream.fromIterable([
1924 {
1925 type: "response-metadata",
1926 id: "resp_next",
1927 modelId: undefined,
1928 timestamp: undefined,
1929 request: undefined
1930 },
1931 finishPart
1932 ])
1933 })
1934 ),
1935 Effect.provideService(ResponseIdTracker.ResponseIdTracker, {
1936 clearUnsafe() {},
1937 markParts: (parts) => {
1938 markedParts = parts
1939 },
1940 prepareUnsafe: (prompt) => {
1941 preparedPrompt = prompt
1942 return Option.none()
1943 }
1944 }),
1945 Effect.provide(ApprovalToolkitLayer)
1946 )
1947
1948 assertDefined(preparedPrompt)
1949 for (const msg of preparedPrompt.content) {
1950 if (msg.role === "assistant") {
1951 strictEqual(msg.content.filter((p) => p.type === "tool-approval-request").length, 0)
1952 }
1953 if (msg.role === "tool") {
1954 strictEqual(msg.content.filter((p) => p.type === "tool-approval-response").length, 0)
1955 }
1956 }
1957 assertDefined(markedParts)
1958 strictEqual(markedParts, preparedPrompt.content)
1959 }))
1960
1961 it.effect("uses tracker prepareUnsafe and markParts when disableToolCallResolution is true", () =>
1962 Effect.gen(function*() {
1963 const toolCallId = "call-tracker-stream-disable"
1964 const approvalId = "approval-tracker-stream-disable"
1965
1966 let capturedOptions: LanguageModel.ProviderOptions | undefined
1967 let preparedPrompt: LanguageModel.ProviderOptions["prompt"] | undefined
1968 let markedParts: ReadonlyArray<object> | undefined
1969 let markedResponseId: string | undefined
1970
1971 const incrementalPrompt = Prompt.make([
1972 Prompt.userMessage({ content: [Prompt.textPart({ text: "incremental" })] })
1973 ])
1974
1975 const prompt: Array<Prompt.Message> = [
1976 Prompt.assistantMessage({
1977 content: [
1978 Prompt.makePart("tool-call", {
1979 id: toolCallId,
1980 name: "ApprovalTool",
1981 params: { action: "delete" },
1982 providerExecuted: false
1983 }),
1984 Prompt.makePart("tool-approval-request", {
1985 approvalId,
1986 toolCallId
1987 })
1988 ]
1989 }),
1990 Prompt.toolMessage({
1991 content: [
1992 Prompt.toolApprovalResponsePart({
1993 approvalId,
1994 approved: true
1995 }),
1996 Prompt.toolResultPart({
1997 id: toolCallId,
1998 name: "ApprovalTool",
1999 result: { result: "approved-result" },
2000 isFailure: false,
2001 providerExecuted: false
2002 })
2003 ]
2004 }),
2005 Prompt.userMessage({ content: [Prompt.textPart({ text: "continue" })] })
2006 ]
2007
2008 yield* LanguageModel.streamText({
2009 prompt,
2010 toolkit: ApprovalToolkit,
2011 disableToolCallResolution: true
2012 }).pipe(
2013 Stream.runDrain,
2014 Effect.provideServiceEffect(
2015 LanguageModel.LanguageModel,
2016 LanguageModel.make({
2017 generateText: () => Effect.succeed([]),
2018 streamText: (options) => {
2019 capturedOptions = options
2020 return Stream.fromIterable([
2021 {
2022 type: "response-metadata",
2023 id: "resp_next",
2024 modelId: undefined,
2025 timestamp: undefined,
2026 request: undefined
2027 },
2028 finishPart
2029 ])
2030 }
2031 })
2032 ),
2033 Effect.provideService(ResponseIdTracker.ResponseIdTracker, {
2034 markParts: (parts, responseId) => {
2035 markedParts = parts
2036 markedResponseId = responseId
2037 },
2038 prepareUnsafe: (prompt) => {
2039 preparedPrompt = prompt
2040 return Option.some({
2041 previousResponseId: "resp_prev",
2042 prompt: incrementalPrompt
2043 })
2044 },
2045 clearUnsafe() {}
2046 }),
2047 Effect.provide(ApprovalToolkitLayer)
2048 )
2049
2050 assertDefined(capturedOptions)
2051 assertDefined(preparedPrompt)
2052 strictEqual(preparedPrompt, capturedOptions.prompt)
2053 strictEqual(capturedOptions.previousResponseId, "resp_prev")
2054 strictEqual(capturedOptions.incrementalPrompt, incrementalPrompt)
2055 for (const msg of preparedPrompt.content) {
2056 if (msg.role === "assistant") {
2057 strictEqual(msg.content.filter((p) => p.type === "tool-approval-request").length, 0)
2058 }
2059 if (msg.role === "tool") {
2060 strictEqual(msg.content.filter((p) => p.type === "tool-approval-response").length, 0)
2061 }
2062 }
2063 assertDefined(markedParts)
2064 strictEqual(markedParts, capturedOptions.prompt.content)
2065 strictEqual(markedResponseId, "resp_next")
2066 }))
2067 })
2068
2069 describe("tool approval", () => {
2070 it.effect("emits tool-approval-request when tool has needsApproval: true", () =>
2071 Effect.gen(function*() {
2072 const parts: Array<Response.StreamPart<Toolkit.Tools<typeof ApprovalToolkit>, "opaque">> = []
2073
2074 const toolCallId = "call-123"
2075 const toolName = "ApprovalTool"
2076 const toolParams = { action: "delete" }
2077
2078 yield* LanguageModel.streamText({
2079 prompt: [],
2080 toolkit: ApprovalToolkit
2081 }).pipe(
2082 Stream.runForEach((part) =>
2083 Effect.sync(() => {
2084 parts.push(part)
2085 })
2086 ),
2087 TestUtils.withLanguageModel({
2088 streamText: [
2089 {
2090 type: "tool-call",
2091 id: toolCallId,
2092 name: toolName,
2093 params: toolParams
2094 }
2095 ]
2096 }),
2097 Effect.provide(ApprovalToolkitLayer)
2098 )
2099
2100 strictEqual(parts.length, 2)
2101 deepStrictEqual(
2102 parts[0],
2103 Response.makePart("tool-call", {
2104 id: toolCallId,
2105 name: toolName,
2106 params: toolParams,
2107 providerExecuted: false
2108 })
2109 )
2110
2111 const approvalPart = parts[1]
2112 strictEqual(approvalPart.type, "tool-approval-request")
2113 if (approvalPart.type === "tool-approval-request") {
2114 strictEqual(approvalPart.toolCallId, toolCallId)
2115 assertDefined(approvalPart.approvalId)
2116 }
2117 }))
2118
2119 it.effect("pre-resolves approved tool calls before calling LLM", () =>
2120 Effect.gen(function*() {
2121 const toolCallId = "call-456"
2122 const approvalId = "approval-456"
2123 let capturedPrompt: LanguageModel.ProviderOptions["prompt"] | undefined
2124
2125 const prompt: Array<Prompt.Message> = [
2126 Prompt.assistantMessage({
2127 content: [
2128 Prompt.makePart("tool-call", {
2129 id: toolCallId,
2130 name: "ApprovalTool",
2131 params: { action: "delete" },
2132 providerExecuted: false
2133 }),
2134 Prompt.makePart("tool-approval-request", {
2135 approvalId,
2136 toolCallId
2137 })
2138 ]
2139 }),
2140 Prompt.toolMessage({
2141 content: [
2142 Prompt.toolApprovalResponsePart({
2143 approvalId,
2144 approved: true
2145 })
2146 ]
2147 })
2148 ]
2149
2150 yield* LanguageModel.streamText({
2151 prompt,
2152 toolkit: ApprovalToolkit
2153 }).pipe(
2154 Stream.runDrain,
2155 TestUtils.withLanguageModel({
2156 streamText: (opts) => {
2157 capturedPrompt = opts.prompt
2158 return [finishPart]
2159 }
2160 }),
2161 Effect.provide(ApprovalToolkitLayer)
2162 )
2163
2164 // Verify the prompt sent to LLM contains pre-resolved tool result
2165 assertDefined(capturedPrompt)
2166 const messages = capturedPrompt.content
2167 const lastMessage = messages[messages.length - 1]
2168 strictEqual(lastMessage.role, "tool")
2169 assertTrue(Array.isArray(lastMessage.content))
2170 const toolResults = (lastMessage.content as Array<Prompt.ToolMessagePart>).filter(
2171 (p): p is Prompt.ToolResultPart => p.type === "tool-result"
2172 )
2173 strictEqual(toolResults.length, 1)
2174 strictEqual(toolResults[0].id, toolCallId)
2175 deepStrictEqual(toolResults[0].result, { result: "approved-result" })
2176 strictEqual(toolResults[0].isFailure, false)
2177 }))
2178
2179 it.effect("pre-resolves denied tool calls with execution-denied before calling LLM", () =>
2180 Effect.gen(function*() {
2181 const toolCallId = "call-789"
2182 const approvalId = "approval-789"
2183 let capturedPrompt: LanguageModel.ProviderOptions["prompt"] | undefined
2184
2185 const prompt: Array<Prompt.Message> = [
2186 Prompt.assistantMessage({
2187 content: [
2188 Prompt.makePart("tool-call", {
2189 id: toolCallId,
2190 name: "ApprovalTool",
2191 params: { action: "delete" },
2192 providerExecuted: false
2193 }),
2194 Prompt.makePart("tool-approval-request", {
2195 approvalId,
2196 toolCallId
2197 })
2198 ]
2199 }),
2200 Prompt.toolMessage({
2201 content: [
2202 Prompt.toolApprovalResponsePart({
2203 approvalId,
2204 approved: false,
2205 reason: "User declined"
2206 })
2207 ]
2208 })
2209 ]
2210
2211 yield* LanguageModel.streamText({
2212 prompt,
2213 toolkit: ApprovalToolkit
2214 }).pipe(
2215 Stream.runDrain,
2216 TestUtils.withLanguageModel({
2217 streamText: (opts) => {
2218 capturedPrompt = opts.prompt
2219 return [finishPart]
2220 }
2221 }),
2222 Effect.provide(ApprovalToolkitLayer)
2223 )
2224
2225 // Verify the prompt sent to LLM contains pre-resolved denial result
2226 assertDefined(capturedPrompt)
2227 const messages = capturedPrompt.content
2228 const lastMessage = messages[messages.length - 1]
2229 strictEqual(lastMessage.role, "tool")
2230 assertTrue(Array.isArray(lastMessage.content))
2231 const toolResults = (lastMessage.content as Array<Prompt.ToolMessagePart>).filter(
2232 (p): p is Prompt.ToolResultPart => p.type === "tool-result"
2233 )
2234 strictEqual(toolResults.length, 1)
2235 strictEqual(toolResults[0].id, toolCallId)
2236 const result = toolResults[0].result as { type: string; reason: string }
2237 strictEqual(result.type, "execution-denied")
2238 strictEqual(result.reason, "User declined")
2239 strictEqual(toolResults[0].isFailure, true)
2240 }))
2241
2242 it.effect("strips approved approval artifacts from prompt sent to provider (streamText)", () =>
2243 Effect.gen(function*() {
2244 const toolCallId = "call-strip"
2245 const approvalId = "approval-strip"
2246 let capturedPrompt: LanguageModel.ProviderOptions["prompt"] | undefined
2247
2248 const prompt: Array<Prompt.Message> = [
2249 Prompt.assistantMessage({
2250 content: [
2251 Prompt.makePart("tool-call", {
2252 id: toolCallId,
2253 name: "ApprovalTool",
2254 params: { action: "delete" },
2255 providerExecuted: false
2256 }),
2257 Prompt.makePart("tool-approval-request", {
2258 approvalId,
2259 toolCallId
2260 })
2261 ]
2262 }),
2263 Prompt.toolMessage({
2264 content: [
2265 Prompt.toolApprovalResponsePart({
2266 approvalId,
2267 approved: true
2268 })
2269 ]
2270 })
2271 ]
2272
2273 yield* LanguageModel.streamText({
2274 prompt,
2275 toolkit: ApprovalToolkit
2276 }).pipe(
2277 Stream.runDrain,
2278 TestUtils.withLanguageModel({
2279 streamText: (opts) => {
2280 capturedPrompt = opts.prompt
2281 return [finishPart]
2282 }
2283 }),
2284 Effect.provide(ApprovalToolkitLayer)
2285 )
2286
2287 assertDefined(capturedPrompt)
2288 const messages = capturedPrompt.content
2289
2290 // Assistant message should retain tool-call but not tool-approval-request
2291 const assistantMsg = messages.find((m) => m.role === "assistant")
2292 assertDefined(assistantMsg)
2293 if (assistantMsg.role === "assistant") {
2294 strictEqual(assistantMsg.content.filter((p) => p.type === "tool-approval-request").length, 0)
2295 strictEqual(assistantMsg.content.filter((p) => p.type === "tool-call").length, 1)
2296 }
2297
2298 // No tool message should contain tool-approval-response parts
2299 for (const msg of messages) {
2300 if (msg.role === "tool") {
2301 strictEqual(msg.content.filter((p) => p.type === "tool-approval-response").length, 0)
2302 }
2303 }
2304 }))
2305
2306 it.effect("strips denied approval artifacts from prompt sent to provider", () =>
2307 Effect.gen(function*() {
2308 const toolCallId = "call-strip-deny"
2309 const approvalId = "approval-strip-deny"
2310 let capturedPrompt: LanguageModel.ProviderOptions["prompt"] | undefined
2311
2312 const prompt: Array<Prompt.Message> = [
2313 Prompt.assistantMessage({
2314 content: [
2315 Prompt.makePart("tool-call", {
2316 id: toolCallId,
2317 name: "ApprovalTool",
2318 params: { action: "delete" },
2319 providerExecuted: false
2320 }),
2321 Prompt.makePart("tool-approval-request", {
2322 approvalId,
2323 toolCallId
2324 })
2325 ]
2326 }),
2327 Prompt.toolMessage({
2328 content: [
2329 Prompt.toolApprovalResponsePart({
2330 approvalId,
2331 approved: false,
2332 reason: "Denied"
2333 })
2334 ]
2335 })
2336 ]
2337
2338 yield* LanguageModel.streamText({
2339 prompt,
2340 toolkit: ApprovalToolkit
2341 }).pipe(
2342 Stream.runDrain,
2343 TestUtils.withLanguageModel({
2344 streamText: (opts) => {
2345 capturedPrompt = opts.prompt
2346 return [finishPart]
2347 }
2348 }),
2349 Effect.provide(ApprovalToolkitLayer)
2350 )
2351
2352 assertDefined(capturedPrompt)
2353 const messages = capturedPrompt.content
2354
2355 // Approval artifacts stripped
2356 for (const msg of messages) {
2357 if (msg.role === "assistant") {
2358 strictEqual(msg.content.filter((p) => p.type === "tool-approval-request").length, 0)
2359 }
2360 if (msg.role === "tool") {
2361 strictEqual(msg.content.filter((p) => p.type === "tool-approval-response").length, 0)
2362 }
2363 }
2364
2365 // Denial result should still be present
2366 const lastMessage = messages[messages.length - 1]
2367 strictEqual(lastMessage.role, "tool")
2368 if (lastMessage.role === "tool") {
2369 const toolResults = lastMessage.content.filter(
2370 (p): p is Prompt.ToolResultPart => p.type === "tool-result"
2371 )
2372 strictEqual(toolResults.length, 1)
2373 strictEqual(toolResults[0].isFailure, true)
2374 }
2375 }))
2376
2377 it.effect("strips only resolved approvals, preserves unrelated parts", () =>
2378 Effect.gen(function*() {
2379 const resolvedCallId = "call-resolved"
2380 const resolvedApprovalId = "approval-resolved"
2381 const unresolvedCallId = "call-unresolved"
2382 const unresolvedApprovalId = "approval-unresolved"
2383 let capturedPrompt: LanguageModel.ProviderOptions["prompt"] | undefined
2384
2385 const prompt: Array<Prompt.Message> = [
2386 Prompt.assistantMessage({
2387 content: [
2388 Prompt.makePart("tool-call", {
2389 id: resolvedCallId,
2390 name: "ApprovalTool",
2391 params: { action: "delete" },
2392 providerExecuted: false
2393 }),
2394 Prompt.makePart("tool-approval-request", {
2395 approvalId: resolvedApprovalId,
2396 toolCallId: resolvedCallId
2397 }),
2398 Prompt.makePart("tool-call", {
2399 id: unresolvedCallId,
2400 name: "ApprovalTool",
2401 params: { action: "read" },
2402 providerExecuted: false
2403 }),
2404 Prompt.makePart("tool-approval-request", {
2405 approvalId: unresolvedApprovalId,
2406 toolCallId: unresolvedCallId
2407 })
2408 ]
2409 }),
2410 // Only resolve one of the two approvals
2411 Prompt.toolMessage({
2412 content: [
2413 Prompt.toolApprovalResponsePart({
2414 approvalId: resolvedApprovalId,
2415 approved: true
2416 })
2417 ]
2418 })
2419 ]
2420
2421 yield* LanguageModel.streamText({
2422 prompt,
2423 toolkit: ApprovalToolkit
2424 }).pipe(
2425 Stream.runDrain,
2426 TestUtils.withLanguageModel({
2427 streamText: (opts) => {
2428 capturedPrompt = opts.prompt
2429 return [finishPart]
2430 }
2431 }),
2432 Effect.provide(ApprovalToolkitLayer)
2433 )
2434
2435 assertDefined(capturedPrompt)
2436 const messages = capturedPrompt.content
2437
2438 // The assistant message should have the resolved approval-request stripped
2439 // but the unresolved one preserved
2440 const assistantMsg = messages.find((m) => m.role === "assistant")
2441 assertDefined(assistantMsg)
2442 if (assistantMsg.role === "assistant") {
2443 const approvalRequests = assistantMsg.content.filter(
2444 (p) => p.type === "tool-approval-request"
2445 )
2446 strictEqual(approvalRequests.length, 1)
2447 if (approvalRequests[0].type === "tool-approval-request") {
2448 strictEqual(approvalRequests[0].approvalId, unresolvedApprovalId)
2449 }
2450 // Both tool-calls should survive
2451 strictEqual(assistantMsg.content.filter((p) => p.type === "tool-call").length, 2)
2452 }
2453 }))
2454
2455 it.effect("strips approval artifacts via generateText path", () =>
2456 Effect.gen(function*() {
2457 const toolCallId = "call-gen"
2458 const approvalId = "approval-gen"
2459 let capturedPrompt: LanguageModel.ProviderOptions["prompt"] | undefined
2460
2461 const prompt: Array<Prompt.Message> = [
2462 Prompt.assistantMessage({
2463 content: [
2464 Prompt.makePart("tool-call", {
2465 id: toolCallId,
2466 name: "ApprovalTool",
2467 params: { action: "delete" },
2468 providerExecuted: false
2469 }),
2470 Prompt.makePart("tool-approval-request", {
2471 approvalId,
2472 toolCallId
2473 })
2474 ]
2475 }),
2476 Prompt.toolMessage({
2477 content: [
2478 Prompt.toolApprovalResponsePart({
2479 approvalId,
2480 approved: true
2481 })
2482 ]
2483 })
2484 ]
2485
2486 yield* LanguageModel.generateText({
2487 prompt,
2488 toolkit: ApprovalToolkit
2489 }).pipe(
2490 TestUtils.withLanguageModel({
2491 generateText: (opts) => {
2492 capturedPrompt = opts.prompt
2493 return Effect.succeed([finishPart])
2494 }
2495 }),
2496 Effect.provide(ApprovalToolkitLayer)
2497 )
2498
2499 assertDefined(capturedPrompt)
2500 const messages = capturedPrompt.content
2501
2502 for (const msg of messages) {
2503 if (msg.role === "assistant") {
2504 strictEqual(msg.content.filter((p) => p.type === "tool-approval-request").length, 0)
2505 strictEqual(msg.content.filter((p) => p.type === "tool-call").length, 1)
2506 }
2507 if (msg.role === "tool") {
2508 strictEqual(msg.content.filter((p) => p.type === "tool-approval-response").length, 0)
2509 }
2510 }
2511 }))
2512
2513 it.effect("dynamic needsApproval returns true when condition met", () =>
2514 Effect.gen(function*() {
2515 const parts: Array<Response.StreamPart<Toolkit.Tools<typeof ApprovalToolkit>, "opaque">> = []
2516
2517 const toolCallId = "call-dyn-1"
2518
2519 yield* LanguageModel.streamText({
2520 prompt: [],
2521 toolkit: ApprovalToolkit
2522 }).pipe(
2523 Stream.runForEach((part) =>
2524 Effect.sync(() => {
2525 parts.push(part)
2526 })
2527 ),
2528 TestUtils.withLanguageModel({
2529 streamText: [
2530 {
2531 type: "tool-call",
2532 id: toolCallId,
2533 name: "DynamicApprovalTool",
2534 params: { dangerous: true }
2535 }
2536 ]
2537 }),
2538 Effect.provide(ApprovalToolkitLayer)
2539 )
2540
2541 strictEqual(parts.length, 2)
2542 strictEqual(parts[0].type, "tool-call")
2543 strictEqual(parts[1].type, "tool-approval-request")
2544 if (parts[1].type === "tool-approval-request") {
2545 strictEqual(parts[1].toolCallId, toolCallId)
2546 }
2547 }))
2548
2549 it.effect("dynamic needsApproval returns false when condition not met", () =>
2550 Effect.gen(function*() {
2551 const parts: Array<Response.StreamPart<Toolkit.Tools<typeof ApprovalToolkit>, "opaque">> = []
2552
2553 const toolCallId = "call-dyn-2"
2554
2555 yield* LanguageModel.streamText({
2556 prompt: [],
2557 toolkit: ApprovalToolkit
2558 }).pipe(
2559 Stream.runForEach((part) =>
2560 Effect.sync(() => {
2561 parts.push(part)
2562 })
2563 ),
2564 TestUtils.withLanguageModel({
2565 streamText: [
2566 {
2567 type: "tool-call",
2568 id: toolCallId,
2569 name: "DynamicApprovalTool",
2570 params: { dangerous: false }
2571 }
2572 ]
2573 }),
2574 Effect.provide(ApprovalToolkitLayer)
2575 )
2576
2577 strictEqual(parts.length, 2)
2578 strictEqual(parts[0].type, "tool-call")
2579 strictEqual(parts[1].type, "tool-result")
2580 if (parts[1].type === "tool-result") {
2581 deepStrictEqual(parts[1].result, { result: "dynamic-result" })
2582 }
2583 }))
2584
2585 it.effect("tool without needsApproval executes normally", () =>
2586 Effect.gen(function*() {
2587 const parts: Array<Response.StreamPart<Toolkit.Tools<typeof MyToolkit>, "opaque">> = []
2588
2589 const toolCallId = "call-normal"
2590 const latch = yield* Latch.make()
2591
2592 const fiber = yield* LanguageModel.streamText({
2593 prompt: [],
2594 toolkit: MyToolkit
2595 }).pipe(
2596 Stream.runForEach((part) =>
2597 Effect.andThen(
2598 latch.open,
2599 Effect.sync(() => {
2600 parts.push(part)
2601 })
2602 )
2603 ),
2604 TestUtils.withLanguageModel({
2605 streamText: [
2606 {
2607 type: "tool-call",
2608 id: toolCallId,
2609 name: "MyTool",
2610 params: { testParam: "test" }
2611 }
2612 ]
2613 }),
2614 Effect.provide(MyToolkitLayer),
2615 Effect.forkScoped
2616 )
2617
2618 yield* latch.await
2619 yield* TestClock.adjust("10 seconds")
2620 yield* Fiber.join(fiber)
2621
2622 strictEqual(parts.length, 2)
2623 strictEqual(parts[0].type, "tool-call")
2624 strictEqual(parts[1].type, "tool-result")
2625 if (parts[1].type === "tool-result") {
2626 deepStrictEqual(parts[1].result, { testSuccess: "test-success" })
2627 }
2628 }))
2629
2630 it.effect("strips previous-round approval artifacts even when no new pending approvals (streamText)", () =>
2631 Effect.gen(function*() {
2632 const toolCallId = "call-prev"
2633 const approvalId = "approval-prev"
2634 let capturedPrompt: LanguageModel.ProviderOptions["prompt"] | undefined
2635
2636 // Simulate a prompt where a previous round's approval was already
2637 // resolved (tool-result exists), but the approval artifacts remain.
2638 const prompt: Array<Prompt.Message> = [
2639 Prompt.assistantMessage({
2640 content: [
2641 Prompt.makePart("tool-call", {
2642 id: toolCallId,
2643 name: "ApprovalTool",
2644 params: { action: "delete" },
2645 providerExecuted: false
2646 }),
2647 Prompt.makePart("tool-approval-request", {
2648 approvalId,
2649 toolCallId
2650 })
2651 ]
2652 }),
2653 Prompt.toolMessage({
2654 content: [
2655 Prompt.toolApprovalResponsePart({
2656 approvalId,
2657 approved: true
2658 }),
2659 Prompt.toolResultPart({
2660 id: toolCallId,
2661 name: "ApprovalTool",
2662 result: { result: "approved-result" },
2663 isFailure: false,
2664 providerExecuted: false
2665 })
2666 ]
2667 }),
2668 // A new user message triggers another round with no new approvals
2669 Prompt.userMessage({ content: [Prompt.textPart({ text: "continue" })] })
2670 ]
2671
2672 yield* LanguageModel.streamText({
2673 prompt,
2674 toolkit: ApprovalToolkit
2675 }).pipe(
2676 Stream.runDrain,
2677 TestUtils.withLanguageModel({
2678 streamText: (opts) => {
2679 capturedPrompt = opts.prompt
2680 return [finishPart]
2681 }
2682 }),
2683 Effect.provide(ApprovalToolkitLayer)
2684 )
2685
2686 assertDefined(capturedPrompt)
2687 const messages = capturedPrompt.content
2688
2689 // Previous-round approval artifacts should be stripped
2690 for (const msg of messages) {
2691 if (msg.role === "assistant") {
2692 strictEqual(msg.content.filter((p) => p.type === "tool-approval-request").length, 0)
2693 }
2694 if (msg.role === "tool") {
2695 strictEqual(msg.content.filter((p) => p.type === "tool-approval-response").length, 0)
2696 }
2697 }
2698
2699 // The tool-result and tool-call should be preserved
2700 const assistantMsg = messages.find((m) => m.role === "assistant")
2701 assertDefined(assistantMsg)
2702 if (assistantMsg.role === "assistant") {
2703 strictEqual(assistantMsg.content.filter((p) => p.type === "tool-call").length, 1)
2704 }
2705 const toolMsg = messages.find((m) => m.role === "tool")
2706 assertDefined(toolMsg)
2707 if (toolMsg.role === "tool") {
2708 strictEqual(toolMsg.content.filter((p) => p.type === "tool-result").length, 1)
2709 }
2710 }))
2711
2712 it.effect("strips previous-round approval artifacts even when no new pending approvals (generateText)", () =>
2713 Effect.gen(function*() {
2714 const toolCallId = "call-prev-gen"
2715 const approvalId = "approval-prev-gen"
2716 let capturedPrompt: LanguageModel.ProviderOptions["prompt"] | undefined
2717
2718 const prompt: Array<Prompt.Message> = [
2719 Prompt.assistantMessage({
2720 content: [
2721 Prompt.makePart("tool-call", {
2722 id: toolCallId,
2723 name: "ApprovalTool",
2724 params: { action: "delete" },
2725 providerExecuted: false
2726 }),
2727 Prompt.makePart("tool-approval-request", {
2728 approvalId,
2729 toolCallId
2730 })
2731 ]
2732 }),
2733 Prompt.toolMessage({
2734 content: [
2735 Prompt.toolApprovalResponsePart({
2736 approvalId,
2737 approved: true
2738 }),
2739 Prompt.toolResultPart({
2740 id: toolCallId,
2741 name: "ApprovalTool",
2742 result: { result: "approved-result" },
2743 isFailure: false,
2744 providerExecuted: false
2745 })
2746 ]
2747 }),
2748 Prompt.userMessage({ content: [Prompt.textPart({ text: "continue" })] })
2749 ]
2750
2751 yield* LanguageModel.generateText({
2752 prompt,
2753 toolkit: ApprovalToolkit
2754 }).pipe(
2755 TestUtils.withLanguageModel({
2756 generateText: (opts) => {
2757 capturedPrompt = opts.prompt
2758 return Effect.succeed([finishPart])
2759 }
2760 }),
2761 Effect.provide(ApprovalToolkitLayer)
2762 )
2763
2764 assertDefined(capturedPrompt)
2765 const messages = capturedPrompt.content
2766
2767 for (const msg of messages) {
2768 if (msg.role === "assistant") {
2769 strictEqual(msg.content.filter((p) => p.type === "tool-approval-request").length, 0)
2770 }
2771 if (msg.role === "tool") {
2772 strictEqual(msg.content.filter((p) => p.type === "tool-approval-response").length, 0)
2773 }
2774 }
2775 }))
2776
2777 it.effect("streamText emits pre-resolved tool results as stream parts", () =>
2778 Effect.gen(function*() {
2779 const toolCallId = "call-emit"
2780 const approvalId = "approval-emit"
2781 const parts: Array<Response.StreamPart<Toolkit.Tools<typeof ApprovalToolkit>, "opaque">> = []
2782
2783 const prompt: Array<Prompt.Message> = [
2784 Prompt.assistantMessage({
2785 content: [
2786 Prompt.makePart("tool-call", {
2787 id: toolCallId,
2788 name: "ApprovalTool",
2789 params: { action: "delete" },
2790 providerExecuted: false
2791 }),
2792 Prompt.makePart("tool-approval-request", {
2793 approvalId,
2794 toolCallId
2795 })
2796 ]
2797 }),
2798 Prompt.toolMessage({
2799 content: [
2800 Prompt.toolApprovalResponsePart({
2801 approvalId,
2802 approved: true
2803 })
2804 ]
2805 })
2806 ]
2807
2808 yield* LanguageModel.streamText({
2809 prompt,
2810 toolkit: ApprovalToolkit
2811 }).pipe(
2812 Stream.runForEach((part) =>
2813 Effect.sync(() => {
2814 parts.push(part)
2815 })
2816 ),
2817 TestUtils.withLanguageModel({
2818 streamText: [finishPart]
2819 }),
2820 Effect.provide(ApprovalToolkitLayer)
2821 )
2822
2823 // Should contain the pre-resolved tool-result as a stream part
2824 const toolResultParts = parts.filter((p) => p.type === "tool-result")
2825 strictEqual(toolResultParts.length, 1)
2826 if (toolResultParts[0].type === "tool-result") {
2827 strictEqual(toolResultParts[0].id, toolCallId)
2828 strictEqual(toolResultParts[0].name, "ApprovalTool")
2829 deepStrictEqual(toolResultParts[0].result, { result: "approved-result" })
2830 strictEqual(toolResultParts[0].isFailure, false)
2831 }
2832 }))
2833 })
2834})