1import { describe, it } from "@effect/vitest"
2import { assertInstanceOf, assertTrue, deepStrictEqual } from "@effect/vitest/utils"
3import { Effect, Schema, Stream } from "effect"
4import { AiError, Response, Tool, Toolkit } from "effect/ai"
5import { TestSchema } from "effect/testing"
6
7describe("Response", () => {
8 for (
9 const [name, failureSchema] of [
10 [
11 "struct",
12 Schema.Struct({
13 type: Schema.Literal("execution-denied"),
14 reason: Schema.String,
15 detail: Schema.String
16 })
17 ],
18 ["unknown", Schema.Unknown],
19 ["record", Schema.Record(Schema.String, Schema.Unknown)]
20 ] as const
21 ) {
22 it.effect(`preserves user failure fields with the ${name} schema`, () =>
23 Effect.gen(function*() {
24 const failure = { type: "execution-denied" as const, reason: "r", detail: "IMPORTANT" }
25 const tool = Tool.make("UserFailure", {
26 success: Schema.Number,
27 failure: failureSchema,
28 failureMode: "return"
29 })
30 const toolkit = Toolkit.make(tool)
31 const handlers = yield* toolkit.pipe(Effect.provide(toolkit.toLayer({
32 UserFailure: () => Effect.fail(failure)
33 })))
34 const results = yield* handlers.handle(tool.name, {}).pipe(Effect.flatMap(Stream.runCollect))
35
36 deepStrictEqual(results, [{
37 result: failure,
38 encodedResult: failure,
39 isFailure: true,
40 failureOrigin: "handler",
41 preliminary: false
42 }], "Toolkit preserves user failure fields")
43
44 const schema = Response.AllParts(toolkit)
45 const decoded = yield* Schema.decodeUnknownEffect(schema)({
46 type: "tool-result",
47 id: "tool-123",
48 name: tool.name,
49 isFailure: true,
50 result: failure
51 })
52
53 assertTrue(decoded.type === "tool-result")
54 assertTrue(decoded.isFailure)
55 deepStrictEqual(decoded.result, failure, "decoded user failure")
56 deepStrictEqual(decoded.encodedResult, failure, "decoded encodedResult")
57
58 const encoded = yield* Schema.encodeEffect(schema)(decoded)
59 assertTrue(encoded.type === "tool-result")
60 deepStrictEqual(encoded.result, failure, "re-encoded user failure")
61 }))
62 }
63
64 it.effect("round trips a returned AiError through Toolkit and AllParts JSON with an unknown failure schema", () =>
65 Effect.gen(function*() {
66 const tool = Tool.make("FrameworkFailure", {
67 success: Schema.Number,
68 failure: Schema.Unknown,
69 failureMode: "return"
70 })
71 const toolkit = Toolkit.make(tool)
72 const failure = new AiError.AiError({
73 module: "Test",
74 method: "FrameworkFailure",
75 reason: new AiError.RateLimitError({})
76 })
77 const handlers = yield* toolkit.pipe(Effect.provide(toolkit.toLayer({
78 FrameworkFailure: () => Effect.fail(failure)
79 })))
80 const results = yield* handlers.handle(tool.name, {}).pipe(Effect.flatMap(Stream.runCollect))
81 const encodedFailure = yield* Schema.encodeEffect(AiError.AiError)(failure)
82
83 deepStrictEqual(results, [{
84 result: failure,
85 encodedResult: encodedFailure,
86 isFailure: true,
87 failureOrigin: "handler",
88 preliminary: false
89 }], "Toolkit failure result")
90
91 const part = Response.makePart("tool-result", {
92 ...results[0],
93 id: "tool-123",
94 name: tool.name,
95 providerExecuted: false
96 })
97 const schema = Response.AllParts(toolkit)
98 const encoded = yield* Schema.encodeEffect(schema)(part)
99 const decoded = yield* Schema.decodeUnknownEffect(schema)(JSON.parse(JSON.stringify(encoded)))
100
101 assertTrue(decoded.type === "tool-result")
102 assertTrue(decoded.isFailure)
103 assertInstanceOf(decoded.result, AiError.AiError)
104 assertInstanceOf(decoded.result.reason, AiError.RateLimitError)
105 deepStrictEqual(decoded.result.module, failure.module)
106 deepStrictEqual(decoded.result.method, failure.method)
107 deepStrictEqual(decoded.encodedResult, encodedFailure, "round-tripped encoded AiError")
108 }))
109
110 describe("provider-defined tool execution failures", () => {
111 const tool = Tool.providerDefined({
112 id: "test.provider_tool",
113 customName: "ProviderTool",
114 providerName: "provider_tool",
115 success: Schema.Unknown
116 })()
117 const schema = Response.AllParts(Toolkit.make(tool))
118
119 for (
120 const result of [
121 { type: "execution-denied", reason: "User declined" },
122 {
123 type: "execution-interrupted",
124 reason: "Tool call execution was interrupted because the response finished with reason \"length\""
125 }
126 ]
127 ) {
128 it.effect(`round trips ${result.type} through AllParts JSON`, () =>
129 Effect.gen(function*() {
130 const part = Response.makePart("tool-result", {
131 id: "tool-123",
132 name: tool.name,
133 isFailure: true,
134 result,
135 encodedResult: result,
136 providerExecuted: false,
137 preliminary: false
138 })
139
140 const encoded = yield* Schema.encodeUnknownEffect(schema)(part)
141 const decoded = yield* Schema.decodeUnknownEffect(schema)(JSON.parse(JSON.stringify(encoded)))
142
143 deepStrictEqual<unknown>(decoded, part, "decoded execution failure")
144 }))
145 }
146 })
147
148 describe("tool results with overlapping success and failure types", () => {
149 const tool = Tool.make("ResultEncoding", {
150 success: Schema.Number,
151 failure: Schema.NumberFromString,
152 failureMode: "return"
153 })
154 const schema = Response.ToolResultPart(tool.name, tool.successSchema, tool.failureSchema)
155 const allParts = Response.AllParts(Toolkit.make(tool))
156
157 for (const isFailure of [false, true]) {
158 const branch = isFailure ? "failure" : "success"
159 const encodedResult = isFailure ? "404" : 404
160 const part = Response.makePart("tool-result", {
161 id: "tool-123",
162 name: "ResultEncoding",
163 isFailure,
164 result: 404,
165 encodedResult,
166 providerExecuted: false,
167 preliminary: false
168 })
169
170 it.effect(`encodes a ${branch} with the ${branch} schema`, () =>
171 Effect.gen(function*() {
172 const encoded = yield* Schema.encodeEffect(schema)(part)
173
174 deepStrictEqual(encoded, {
175 type: "tool-result",
176 id: "tool-123",
177 name: "ResultEncoding",
178 isFailure,
179 result: encodedResult,
180 providerExecuted: false,
181 preliminary: false,
182 metadata: {}
183 }, "encoded tool result")
184 }))
185
186 it(`rejects an encodedResult from the other branch for a ${branch}`, () =>
187 new TestSchema.Asserts(schema).encoding().fail(
188 { ...part, encodedResult: isFailure ? 404 : "404" },
189 `Expected ${isFailure ? "string" : "number"}\n at ["encodedResult"]`
190 ))
191
192 it.effect(`preserves a ${branch} encodedResult through an AllParts JSON round trip`, () =>
193 Effect.gen(function*() {
194 const encoded = yield* Schema.encodeEffect(allParts)(part)
195 const decoded = yield* Schema.decodeUnknownEffect(allParts)(JSON.parse(JSON.stringify(encoded)))
196
197 deepStrictEqual(decoded, part, "decoded tool result")
198 }))
199 }
200 })
201
202 it.effect("decodes response metadata with omitted optional fields", () =>
203 Effect.gen(function*() {
204 const encoded: Response.ResponseMetadataPartEncoded = {
205 type: "response-metadata"
206 }
207
208 const decoded = yield* Schema.decodeUnknownEffect(Response.ResponseMetadataPart)(encoded)
209
210 deepStrictEqual(decoded, Response.makePart("response-metadata", {}))
211 }))
212
213 it.effect("round trips response metadata with undefined optional fields through JSON", () =>
214 Effect.gen(function*() {
215 const part = Response.makePart("response-metadata", {
216 id: undefined,
217 modelId: undefined,
218 timestamp: undefined,
219 request: undefined
220 })
221
222 const encoded = yield* Schema.encodeEffect(Response.ResponseMetadataPart)(part)
223 const json = JSON.parse(JSON.stringify(encoded))
224 const decoded = yield* Schema.decodeUnknownEffect(Response.ResponseMetadataPart)(json)
225
226 deepStrictEqual(json, {
227 metadata: {},
228 type: "response-metadata"
229 }, "encoded JSON")
230 deepStrictEqual(decoded, Response.makePart("response-metadata", {}), "decoded part")
231 }))
232
233 it.effect("round trips HTTP request details with an undefined hash through JSON", () =>
234 Effect.gen(function*() {
235 const request: typeof Response.HttpRequestDetails.Type = {
236 method: "POST",
237 url: "https://example.com/v1/responses",
238 urlParams: [],
239 hash: undefined,
240 headers: {}
241 }
242
243 const encoded = yield* Schema.encodeEffect(Response.HttpRequestDetails)(request)
244 const json = JSON.parse(JSON.stringify(encoded))
245 const decoded = yield* Schema.decodeUnknownEffect(Response.HttpRequestDetails)(json)
246
247 deepStrictEqual(json, {
248 method: "POST",
249 url: "https://example.com/v1/responses",
250 urlParams: [],
251 headers: {}
252 }, "encoded JSON")
253 deepStrictEqual(decoded, {
254 method: "POST",
255 url: "https://example.com/v1/responses",
256 urlParams: [],
257 headers: {}
258 }, "decoded request")
259 }))
260
261 it.effect("round trips a finish part with undefined optional fields through JSON", () =>
262 Effect.gen(function*() {
263 const part = Response.makePart("finish", {
264 reason: "stop",
265 usage: new Response.Usage({
266 inputTokens: {
267 uncached: undefined,
268 total: undefined,
269 cacheRead: undefined,
270 cacheWrite: undefined
271 },
272 outputTokens: {
273 total: undefined,
274 text: undefined,
275 reasoning: undefined
276 }
277 }),
278 response: undefined
279 })
280
281 const encoded = yield* Schema.encodeEffect(Response.FinishPart)(part)
282 const json = JSON.parse(JSON.stringify(encoded))
283 const decoded = yield* Schema.decodeUnknownEffect(Response.FinishPart)(json)
284
285 deepStrictEqual(json, {
286 metadata: {},
287 type: "finish",
288 reason: "stop",
289 usage: {
290 inputTokens: {},
291 outputTokens: {}
292 }
293 }, "encoded JSON")
294 deepStrictEqual(
295 decoded,
296 Response.makePart("finish", {
297 reason: "stop",
298 usage: new Response.Usage({
299 inputTokens: {},
300 outputTokens: {}
301 })
302 }),
303 "decoded part"
304 )
305 }))
306})