1import { assert, describe, it } from "@effect/vitest"
2import { Schema } from "effect"
3import { Prompt, Response } from "effect/ai"
4
5describe("Prompt", () => {
6 describe("serialization", () => {
7 it("preserves text parts during serialization", () => {
8 const prompt = Prompt.make([
9 {
10 role: "user",
11 content: [
12 { type: "text", text: "first" },
13 { type: "text", text: "second" }
14 ]
15 },
16 {
17 role: "assistant",
18 content: [{ type: "text", text: "response", options: { test: { cache: "ephemeral" } } }]
19 }
20 ])
21
22 assert.deepStrictEqual(
23 Schema.decodeSync(Prompt.Prompt)(Schema.encodeSync(Prompt.Prompt)(prompt)),
24 prompt
25 )
26 })
27 })
28
29 describe("part schemas", () => {
30 it("exports schemas for all parts and message-specific parts", () => {
31 const parts = {
32 text: { type: "text", text: "hello" },
33 reasoning: { type: "reasoning", text: "thinking" },
34 file: { type: "file", mediaType: "text/plain", data: "hello" },
35 toolCall: { type: "tool-call", id: "call-1", name: "tool", params: {} },
36 toolResult: { type: "tool-result", id: "call-1", name: "tool", isFailure: false, result: "done" },
37 toolApprovalResponse: {
38 type: "tool-approval-response",
39 approvalId: "approval-1",
40 approved: true
41 },
42 toolApprovalRequest: {
43 type: "tool-approval-request",
44 approvalId: "approval-1",
45 toolCallId: "call-1"
46 }
47 } as const
48
49 const decodePart = Schema.decodeUnknownExit(Prompt.Part)
50 for (const part of Object.values(parts)) {
51 assert.strictEqual(decodePart(part)._tag, "Success")
52 }
53
54 const decodeUserMessagePart = Schema.decodeUnknownExit(Prompt.UserMessagePart)
55 for (const part of [parts.text, parts.file]) {
56 assert.strictEqual(decodeUserMessagePart(part)._tag, "Success")
57 }
58
59 const decodeAssistantMessagePart = Schema.decodeUnknownExit(Prompt.AssistantMessagePart)
60 for (
61 const part of [
62 parts.text,
63 parts.file,
64 parts.reasoning,
65 parts.toolCall,
66 parts.toolResult,
67 parts.toolApprovalRequest
68 ]
69 ) {
70 assert.strictEqual(decodeAssistantMessagePart(part)._tag, "Success")
71 }
72
73 const decodeToolMessagePart = Schema.decodeUnknownExit(Prompt.ToolMessagePart)
74 for (const part of [parts.toolResult, parts.toolApprovalResponse]) {
75 assert.strictEqual(decodeToolMessagePart(part)._tag, "Success")
76 }
77 })
78 })
79
80 describe("fromResponseParts", () => {
81 it("preserves file-only responses as assistant messages", () => {
82 const file = Response.makePart("file", {
83 data: new Uint8Array([0, 127, 128, 255]),
84 mediaType: "application/octet-stream",
85 metadata: { example: { id: "attachment-1", generated: true } }
86 })
87
88 assert.deepStrictEqual(
89 Prompt.fromResponseParts([file]),
90 Prompt.make([{
91 role: "assistant",
92 content: [{
93 type: "file",
94 data: "AH+A/w==",
95 mediaType: "application/octet-stream",
96 options: { example: { id: "attachment-1", generated: true } }
97 }]
98 }])
99 )
100 })
101
102 it("folds streamed text and reasoning deltas into an assistant message", () => {
103 const parts = [
104 Response.makePart("text-start", { id: "1" }),
105 Response.makePart("text-delta", { id: "1", delta: "Hello" }),
106 Response.makePart("text-delta", { id: "1", delta: ", " }),
107 Response.makePart("text-delta", { id: "1", delta: "World!" }),
108 Response.makePart("text-end", { id: "1" }),
109 Response.makePart("reasoning-start", { id: "2" }),
110 Response.makePart("reasoning-delta", { id: "2", delta: "I " }),
111 Response.makePart("reasoning-delta", { id: "2", delta: "am " }),
112 Response.makePart("reasoning-delta", { id: "2", delta: "thinking" }),
113 Response.makePart("reasoning-end", { id: "2" })
114 ]
115 const prompt = Prompt.fromResponseParts(parts)
116 const expected = Prompt.make([
117 {
118 role: "assistant",
119 content: [
120 { type: "text", text: "Hello, World!" },
121 { type: "reasoning", text: "I am thinking" }
122 ]
123 }
124 ])
125 assert.deepStrictEqual(prompt, expected)
126 })
127
128 it("preserves metadata on non-streaming response parts", () => {
129 const parts = [
130 Response.makePart("text", {
131 text: "Hello",
132 metadata: { test: { value: "text" } }
133 }),
134 Response.makePart("reasoning", {
135 text: "Thinking",
136 metadata: { openai: { encryptedContent: "encrypted-reasoning" } }
137 }),
138 Response.makePart("tool-call", {
139 id: "call-1",
140 name: "get_weather",
141 params: { city: "London" },
142 providerExecuted: false,
143 metadata: { google: { thoughtSignature: "signed-call" } }
144 }),
145 Response.makePart("tool-result", {
146 id: "call-1",
147 name: "get_weather",
148 isFailure: false,
149 result: { temp: 20 },
150 encodedResult: { temp: 20 },
151 preliminary: false,
152 providerExecuted: false,
153 metadata: { test: { value: "tool-result" } }
154 })
155 ]
156
157 const prompt = Prompt.fromResponseParts(parts)
158
159 assert.deepStrictEqual(
160 prompt,
161 Prompt.make([
162 {
163 role: "assistant",
164 content: [
165 { type: "text", text: "Hello", options: { test: { value: "text" } } },
166 {
167 type: "reasoning",
168 text: "Thinking",
169 options: { openai: { encryptedContent: "encrypted-reasoning" } }
170 },
171 {
172 type: "tool-call",
173 id: "call-1",
174 name: "get_weather",
175 params: { city: "London" },
176 providerExecuted: false,
177 options: { google: { thoughtSignature: "signed-call" } }
178 }
179 ]
180 },
181 {
182 role: "tool",
183 content: [{
184 type: "tool-result",
185 id: "call-1",
186 name: "get_weather",
187 isFailure: false,
188 result: { temp: 20 },
189 providerExecuted: false,
190 options: { test: { value: "tool-result" } }
191 }]
192 }
193 ])
194 )
195 })
196
197 it("accumulates metadata across streamed text and reasoning parts", () => {
198 const parts = [
199 Response.makePart("text-start", {
200 id: "text-1",
201 metadata: { testStart: { value: "text-start" } }
202 }),
203 Response.makePart("text-delta", {
204 id: "text-1",
205 delta: "Hello",
206 metadata: { testDelta: { value: "text-delta" } }
207 }),
208 Response.makePart("text-end", {
209 id: "text-1",
210 metadata: { testEnd: { value: "text-end" } }
211 }),
212 Response.makePart("reasoning-start", {
213 id: "reasoning-1",
214 metadata: { openai: { itemId: "reasoning-item" } }
215 }),
216 Response.makePart("reasoning-delta", {
217 id: "reasoning-1",
218 delta: "Thinking"
219 }),
220 Response.makePart("reasoning-delta", {
221 id: "reasoning-1",
222 delta: "",
223 metadata: { testDelta: { value: "reasoning-delta" } }
224 }),
225 Response.makePart("reasoning-end", {
226 id: "reasoning-1",
227 metadata: {
228 openai: { encryptedContent: "encrypted-reasoning" },
229 testEnd: { value: "reasoning-end" }
230 }
231 })
232 ]
233
234 const prompt = Prompt.fromResponseParts(parts)
235
236 assert.deepStrictEqual(
237 prompt,
238 Prompt.make([{
239 role: "assistant",
240 content: [
241 {
242 type: "text",
243 text: "Hello",
244 options: {
245 testStart: { value: "text-start" },
246 testDelta: { value: "text-delta" },
247 testEnd: { value: "text-end" }
248 }
249 },
250 {
251 type: "reasoning",
252 text: "Thinking",
253 options: {
254 openai: {
255 itemId: "reasoning-item",
256 encryptedContent: "encrypted-reasoning"
257 },
258 testDelta: { value: "reasoning-delta" },
259 testEnd: { value: "reasoning-end" }
260 }
261 }
262 ]
263 }])
264 )
265 })
266
267 it("places tool calls in assistant messages and tool results in tool messages", () => {
268 const parts = [
269 Response.makePart("tool-call", {
270 id: "call-1",
271 name: "get_weather",
272 params: { city: "London" },
273 providerExecuted: false
274 }),
275 Response.makePart("tool-result", {
276 id: "call-1",
277 name: "get_weather",
278 isFailure: false,
279 result: { temp: 20 },
280 encodedResult: { temp: 20 },
281 preliminary: false,
282 providerExecuted: false
283 })
284 ]
285 const prompt = Prompt.fromResponseParts(parts)
286
287 // Should have assistant message with tool-call, then tool message with tool-result
288 assert.strictEqual(prompt.content.length, 2)
289 assert.strictEqual(prompt.content[0].role, "assistant")
290 assert.strictEqual(prompt.content[1].role, "tool")
291
292 // Assistant message contains the tool-call
293 const assistantContent = prompt.content[0].content
294 assert.strictEqual(assistantContent.length, 1)
295 assert.strictEqual(typeof assistantContent[0] === "object" && assistantContent[0].type, "tool-call")
296
297 // Tool message contains the tool-result
298 const toolContent = prompt.content[1].content
299 assert.strictEqual(toolContent.length, 1)
300 assert.strictEqual(typeof toolContent[0] === "object" && toolContent[0].type, "tool-result")
301 })
302
303 it("places provider-executed tool results in the assistant message", () => {
304 const parts = [
305 Response.makePart("tool-call", {
306 id: "ws-1",
307 name: "web_search",
308 params: {},
309 providerExecuted: true
310 }),
311 Response.makePart("tool-result", {
312 id: "ws-1",
313 name: "web_search",
314 isFailure: false,
315 result: { sources: 3 },
316 encodedResult: { sources: 3 },
317 preliminary: false,
318 providerExecuted: true
319 }),
320 Response.makePart("tool-call", {
321 id: "call-1",
322 name: "get_weather",
323 params: { city: "London" },
324 providerExecuted: false
325 }),
326 Response.makePart("tool-result", {
327 id: "call-1",
328 name: "get_weather",
329 isFailure: false,
330 result: { temp: 20 },
331 encodedResult: { temp: 20 },
332 preliminary: false,
333 providerExecuted: false
334 })
335 ]
336 const prompt = Prompt.fromResponseParts(parts)
337
338 // Provider-executed pair stays in the assistant message; only the
339 // framework-executed result forms a tool message
340 assert.strictEqual(prompt.content.length, 2)
341 assert.strictEqual(prompt.content[0].role, "assistant")
342 assert.strictEqual(prompt.content[1].role, "tool")
343
344 const assistantContent = prompt.content[0].content
345 assert.strictEqual(assistantContent.length, 3)
346 assert.strictEqual(typeof assistantContent[1] === "object" && assistantContent[1].type, "tool-result")
347 assert.deepStrictEqual((assistantContent[1] as any).id, "ws-1")
348 assert.strictEqual((assistantContent[1] as any).providerExecuted, true)
349
350 const toolContent = prompt.content[1].content
351 assert.strictEqual(toolContent.length, 1)
352 assert.deepStrictEqual((toolContent[0] as any).id, "call-1")
353 assert.strictEqual((toolContent[0] as any).providerExecuted, false)
354 })
355
356 it("should handle out-of-order tool results (result before call in stream)", () => {
357 // This simulates concurrent tool execution where results may arrive
358 // in different order than their corresponding calls
359 const parts = [
360 // Tool call A arrives first
361 Response.makePart("tool-call", {
362 id: "call-A",
363 name: "tool_a",
364 params: {},
365 providerExecuted: false
366 }),
367 // Tool call B arrives second
368 Response.makePart("tool-call", {
369 id: "call-B",
370 name: "tool_b",
371 params: {},
372 providerExecuted: false
373 }),
374 // But tool B's result arrives before tool A's result
375 Response.makePart("tool-result", {
376 id: "call-B",
377 name: "tool_b",
378 isFailure: false,
379 result: "result-B",
380 encodedResult: "result-B",
381 preliminary: false,
382 providerExecuted: false
383 }),
384 Response.makePart("tool-result", {
385 id: "call-A",
386 name: "tool_a",
387 isFailure: false,
388 result: "result-A",
389 encodedResult: "result-A",
390 preliminary: false,
391 providerExecuted: false
392 })
393 ]
394 const prompt = Prompt.fromResponseParts(parts)
395
396 // Should still produce valid message structure
397 assert.strictEqual(prompt.content.length, 2)
398 assert.strictEqual(prompt.content[0].role, "assistant")
399 assert.strictEqual(prompt.content[1].role, "tool")
400
401 // Assistant message has tool calls in order they arrived
402 const assistantContent = prompt.content[0].content
403 assert.strictEqual(assistantContent.length, 2)
404 assert.strictEqual(typeof assistantContent[0] === "object" && assistantContent[0].type, "tool-call")
405 assert.deepStrictEqual((assistantContent[0] as any).id, "call-A")
406 assert.strictEqual(typeof assistantContent[1] === "object" && assistantContent[1].type, "tool-call")
407 assert.deepStrictEqual((assistantContent[1] as any).id, "call-B")
408
409 // Tool message has results in order they arrived (B before A)
410 const toolContent = prompt.content[1].content
411 assert.strictEqual(toolContent.length, 2)
412 assert.strictEqual(typeof toolContent[0] === "object" && toolContent[0].type, "tool-result")
413 assert.deepStrictEqual((toolContent[0] as any).id, "call-B")
414 assert.strictEqual(typeof toolContent[1] === "object" && toolContent[1].type, "tool-result")
415 assert.deepStrictEqual((toolContent[1] as any).id, "call-A")
416 })
417
418 it("keeps only the final non-preliminary tool result", () => {
419 const parts = [
420 Response.makePart("tool-call", {
421 id: "call-1",
422 name: "long_task",
423 params: {},
424 providerExecuted: false
425 }),
426 // Preliminary result (progress update)
427 Response.makePart("tool-result", {
428 id: "call-1",
429 name: "long_task",
430 isFailure: false,
431 result: { progress: 50 },
432 encodedResult: { progress: 50 },
433 preliminary: true,
434 providerExecuted: false
435 }),
436 // Another preliminary result
437 Response.makePart("tool-result", {
438 id: "call-1",
439 name: "long_task",
440 isFailure: false,
441 result: { progress: 100 },
442 encodedResult: { progress: 100 },
443 preliminary: true,
444 providerExecuted: false
445 }),
446 // Final result
447 Response.makePart("tool-result", {
448 id: "call-1",
449 name: "long_task",
450 isFailure: false,
451 result: { done: true },
452 encodedResult: { done: true },
453 preliminary: false,
454 providerExecuted: false
455 })
456 ]
457 const prompt = Prompt.fromResponseParts(parts)
458
459 // Should have assistant and tool messages
460 assert.strictEqual(prompt.content.length, 2)
461
462 // Tool message should only contain the final result, not preliminary ones
463 const toolContent = prompt.content[1].content
464 assert.strictEqual(toolContent.length, 1)
465 assert.deepStrictEqual((toolContent[0] as any).result, { done: true })
466 })
467 })
468
469 describe("merge", () => {
470 it("should sequentially combine the content of two Prompts", () => {
471 const leftMessages = [
472 Prompt.makeMessage("user", {
473 content: [Prompt.makePart("text", { text: "a" })]
474 }),
475 Prompt.makeMessage("assistant", {
476 content: [Prompt.makePart("text", { text: "b" })]
477 })
478 ]
479 const rightMessages = [
480 Prompt.makeMessage("user", {
481 content: [Prompt.makePart("text", { text: "c" })]
482 }),
483 Prompt.makeMessage("assistant", {
484 content: [Prompt.makePart("text", { text: "d" })]
485 })
486 ]
487 const left = Prompt.fromMessages(leftMessages)
488 const right = Prompt.fromMessages(rightMessages)
489 const merged = Prompt.concat(left, right)
490 assert.deepStrictEqual(
491 merged,
492 Prompt.fromMessages([
493 ...leftMessages,
494 ...rightMessages
495 ])
496 )
497 })
498
499 it("returns Prompt.empty when both inputs have no messages", () => {
500 const merged = Prompt.concat(Prompt.empty, [])
501 assert.deepStrictEqual(merged, Prompt.empty)
502 })
503
504 it("accepts raw message input when the left prompt is empty", () => {
505 const prompt = Prompt.empty
506 const merged = Prompt.concat(prompt, [
507 { role: "user", content: "a" },
508 { role: "assistant", content: "b" }
509 ])
510 assert.deepStrictEqual(
511 merged,
512 Prompt.make([
513 Prompt.makeMessage("user", {
514 content: [Prompt.makePart("text", { text: "a" })]
515 }),
516 Prompt.makeMessage("assistant", {
517 content: [Prompt.makePart("text", { text: "b" })]
518 })
519 ])
520 )
521 })
522
523 it("returns the original prompt when the input has no messages", () => {
524 const messages = [
525 Prompt.makeMessage("user", {
526 content: [Prompt.makePart("text", { text: "a" })]
527 }),
528 Prompt.makeMessage("assistant", {
529 content: [Prompt.makePart("text", { text: "b" })]
530 })
531 ]
532 const prompt = Prompt.fromMessages(messages)
533 const merged = Prompt.concat(prompt, [])
534 assert.deepStrictEqual(merged, prompt)
535 })
536 })
537
538 describe("appendSystem", () => {
539 it("appends text to the leading system message", () => {
540 const prompt = Prompt.make([
541 { role: "system", content: "You are an expert in programming." },
542 { role: "user", content: "Hello, world!" }
543 ])
544
545 const result = Prompt.appendSystem(prompt, " You are a helpful assistant.")
546
547 assert.deepStrictEqual(
548 result.content[0],
549 Prompt.makeMessage("system", {
550 content: "You are an expert in programming. You are a helpful assistant."
551 })
552 )
553 })
554
555 it("creates a leading system message if none exists", () => {
556 const prompt = Prompt.make([
557 { role: "user", content: "Hello, world!" }
558 ])
559
560 const result = Prompt.appendSystem(prompt, "You are a helpful assistant.")
561
562 assert.deepStrictEqual(
563 result.content[0],
564 Prompt.makeMessage("system", {
565 content: "You are a helpful assistant."
566 })
567 )
568 })
569
570 it("creates a system message for an empty prompt", () => {
571 const prompt = Prompt.empty
572
573 const result = Prompt.appendSystem(prompt, "You are a helpful assistant.")
574
575 assert.deepStrictEqual(
576 result.content[0],
577 Prompt.makeMessage("system", { content: "You are a helpful assistant." })
578 )
579 })
580 })
581
582 describe("prependSystem", () => {
583 it("prepends text to the leading system message", () => {
584 const prompt = Prompt.make([
585 {
586 role: "system",
587 content: "You are an expert in programming."
588 },
589 {
590 role: "user",
591 content: "Hello, world!"
592 }
593 ])
594
595 const result = Prompt.prependSystem(prompt, "You are a helpful assistant. ")
596
597 assert.deepStrictEqual(
598 result.content[0],
599 Prompt.makeMessage("system", {
600 content: "You are a helpful assistant. You are an expert in programming."
601 })
602 )
603 })
604
605 it("creates a leading system message if none exists", () => {
606 const prompt = Prompt.make([
607 {
608 role: "user",
609 content: "Hello, world!"
610 }
611 ])
612
613 const result = Prompt.prependSystem(prompt, "You are a helpful assistant.")
614
615 assert.deepStrictEqual(
616 result.content[0],
617 Prompt.makeMessage("system", {
618 content: "You are a helpful assistant."
619 })
620 )
621 })
622
623 it("creates a system message for an empty prompt", () => {
624 const prompt = Prompt.empty
625
626 const result = Prompt.prependSystem(prompt, "You are a helpful assistant.")
627
628 assert.deepStrictEqual(
629 result.content[0],
630 Prompt.makeMessage("system", { content: "You are a helpful assistant." })
631 )
632 })
633 })
634})