diff --git a/.changeset/full-toes-say.md b/.changeset/full-toes-say.md new file mode 100644 index 00000000000..ecbaa1b988a --- /dev/null +++ b/.changeset/full-toes-say.md @@ -0,0 +1,5 @@ +--- +"@effect/ai": minor +--- + +Added ai response accumulator parts and useful utilities to accumulate from streaming parts and normal parts diff --git a/packages/ai/ai/src/Prompt.ts b/packages/ai/ai/src/Prompt.ts index e1e6d105ef8..8031606d497 100644 --- a/packages/ai/ai/src/Prompt.ts +++ b/packages/ai/ai/src/Prompt.ts @@ -1513,10 +1513,12 @@ export const fromMessages = (messages: ReadonlyArray): Prompt => makePr const VALID_RESPONSE_PART_MAP = { "response-metadata": false, "text": true, + "text-accumulated": true, "text-start": false, "text-delta": true, "text-end": false, "reasoning": true, + "reasoning-accumulated": true, "reasoning-start": false, "reasoning-delta": true, "reasoning-end": false, @@ -1527,6 +1529,7 @@ const VALID_RESPONSE_PART_MAP = { "tool-params-end": false, "tool-call": true, "tool-result": true, + "tool": true, "finish": false, "error": false } as const satisfies Record @@ -1659,6 +1662,84 @@ export const fromResponseParts = (parts: ReadonlyArray): Promp })) break } + case "tool": { + flushDeltas() + if (part.value.state === "params-done") { + assistantParts.push( + makePart("tool-call", { + id: part.id, + name: part.providerName ?? part.name, + params: part.value.params, + providerExecuted: part.providerExecuted ?? false + }) + ) + } else if (part.value.state === "result-error") { + assistantParts.push( + makePart("tool-call", { + id: part.id, + name: part.providerName ?? part.name, + params: part.value.params, + providerExecuted: part.providerExecuted ?? false + }) + ) + + toolParts.push( + makePart("tool-result", { + id: part.id, + name: part.providerName ?? part.name, + result: part.value.encodedResult, + isFailure: true + }) + ) + } else if (part.value.state === "result-done") { + assistantParts.push( + makePart("tool-call", { + id: part.id, + name: part.providerName ?? part.name, + params: part.value.params, + providerExecuted: part.providerExecuted ?? false + }) + ) + + toolParts.push( + makePart("tool-result", { + id: part.id, + name: part.providerName ?? part.name, + result: part.value.encodedResult, + isFailure: false + }) + ) + } + break + } + case "text-accumulated": { + if (part.state === "streaming") { + flushReasoningDeltas() + textDeltas.push(part.text) + } else { + flushDeltas() + assistantParts.push( + makePart("text", { + text: part.text + }) + ) + } + break + } + case "reasoning-accumulated": { + if (part.state === "streaming") { + flushTextDeltas() + reasoningDeltas.push(part.text) + } else { + flushDeltas() + assistantParts.push( + makePart("reasoning", { + text: part.text + }) + ) + } + break + } } } } diff --git a/packages/ai/ai/src/Response.ts b/packages/ai/ai/src/Response.ts index 3d1806dd15c..a47ec7b293c 100644 --- a/packages/ai/ai/src/Response.ts +++ b/packages/ai/ai/src/Response.ts @@ -29,10 +29,14 @@ import type * as DateTime from "effect/DateTime" import * as Effect from "effect/Effect" import { constFalse } from "effect/Function" +import * as Function from "effect/Function" import type * as Option from "effect/Option" import * as ParseResult from "effect/ParseResult" import * as Predicate from "effect/Predicate" import * as Schema from "effect/Schema" +import type * as Types from "effect/Types" +import type * as AiError from "./AiError.js" +import * as IdGenerator from "./IdGenerator.js" import type * as Tool from "./Tool.js" import type * as Toolkit from "./Toolkit.js" @@ -74,10 +78,12 @@ export const isPart = (u: unknown): u is AnyPart => Predicate.hasProperty(u, Par */ export type AnyPart = | TextPart + | TextAccumulatedPart | TextStartPart | TextDeltaPart | TextEndPart | ReasoningPart + | ReasoningAccumulatedPart | ReasoningStartPart | ReasoningDeltaPart | ReasoningEndPart @@ -86,6 +92,7 @@ export type AnyPart = | ToolParamsEndPart | ToolCallPart | ToolResultPart + | ToolPart | FilePart | DocumentSourcePart | UrlSourcePart @@ -101,10 +108,12 @@ export type AnyPart = */ export type AnyPartEncoded = | TextPartEncoded + | TextAccumulatedPartEncoded | TextStartPartEncoded | TextDeltaPartEncoded | TextEndPartEncoded | ReasoningPartEncoded + | ReasoningAccumulatedPartEncoded | ReasoningStartPartEncoded | ReasoningDeltaPartEncoded | ReasoningEndPartEncoded @@ -113,6 +122,7 @@ export type AnyPartEncoded = | ToolParamsEndPartEncoded | ToolCallPartEncoded | ToolResultPartEncoded + | ToolPartEncoded | FilePartEncoded | DocumentSourcePartEncoded | UrlSourcePartEncoded @@ -393,6 +403,87 @@ export const StreamPart = >( ) as any } +// ============================================================================= +// Accumulated Parts +// ============================================================================= + +/** + * A type for representing accumulating response parts with tool-specific typing. + * + * @template Tools - Record of tools with their schemas + * + * @since 1.0.0 + * @category Models + */ +export type AccumulatedPart> = + | TextAccumulatedPart + | ReasoningAccumulatedPart + | ToolParts + | FilePart + | DocumentSourcePart + | UrlSourcePart + | ResponseMetadataPart + | FinishPart + | ErrorPart + +/** + * Encoded representation of accumulating response parts for serialization. + * + * @since 1.0.0 + * @category Models + */ +export type AccumulatedPartEncoded = + | TextAccumulatedPartEncoded + | ReasoningAccumulatedPartEncoded + | ToolPartEncoded + | FilePartEncoded + | DocumentSourcePartEncoded + | UrlSourcePartEncoded + | ResponseMetadataPartEncoded + | FinishPartEncoded + | ErrorPartEncoded + +/** + * Creates a Schema for accumulating response parts based on a toolkit. + * + * @since 1.0.0 + * @category Schemas + */ +export const AccumulatedPart = < + T extends Toolkit.Any | Toolkit.WithHandler +>( + toolkit: T +): Schema.Schema>, AccumulatedPartEncoded> => { + const tools: Array> = [] + + for ( + const tool of Object.values( + toolkit.tools as Record + ) + ) { + tools.push( + ToolPart( + tool.name, + tool.parametersSchema as any, + tool.successSchema, + tool.failureSchema + ) as any + ) + } + + return Schema.Union( + TextAccumulatedPart, + ReasoningAccumulatedPart, + FilePart, + DocumentSourcePart, + UrlSourcePart, + ResponseMetadataPart, + FinishPart, + ErrorPart, + ...(tools as any) + ) as any +} + // ============================================================================= // Utility Types // ============================================================================= @@ -428,6 +519,411 @@ export type ToolResultParts> = { : never }[keyof Tools] +/** + * Utility type that extracts tool parts from a set of tools. + * + * @template Tools - Record of tools with their schemas + * + * @since 1.0.0 + * @category Utility Types + */ +export type ToolParts> = { + [Name in keyof Tools]: Name extends string ? ToolPart< + Name, + Tool.Parameters, + Tool.Success, + Tool.Failure + > + : never +}[keyof Tools] + +// ============================================================================= +// Utilities +// ============================================================================= + +export const mergeAccumulatedParts = Effect.fnUntraced(function*< + Tools extends Record +>(accumulatedParts: Array>) { + const parts: Array>> = [] + + const toolMap = new Map< + string, + Types.DeepMutable> + >() + + const textAccumulatedMap = new Map< + string, + Types.DeepMutable + >() + + const reasoningAccumulatedMap = new Map< + string, + Types.DeepMutable + >() + + for (let i = 0; i < accumulatedParts.length; i++) { + const currentPart = accumulatedParts[i] as Types.DeepMutable> + + switch (currentPart.type) { + case "text-accumulated": { + const textAccumulatedExists = textAccumulatedMap.get(currentPart.id) + + if ( + textAccumulatedExists + ) { + textAccumulatedExists.status = currentPart.status + textAccumulatedExists.text += currentPart.text + + if (textAccumulatedExists.status === "done") { + textAccumulatedMap.delete(currentPart.id) + } + } else { + textAccumulatedMap.set(currentPart.id, currentPart) + parts.push(currentPart) + } + + break + } + + case "reasoning-accumulated": { + const reasoningAccumulatedExists = reasoningAccumulatedMap.get(currentPart.id) + + if ( + reasoningAccumulatedExists + ) { + reasoningAccumulatedExists.status = currentPart.status + reasoningAccumulatedExists.text += currentPart.text + + if (reasoningAccumulatedExists.status === "done") { + reasoningAccumulatedMap.delete(currentPart.id) + } + } else { + reasoningAccumulatedMap.set(currentPart.id, currentPart) + parts.push(currentPart) + } + + break + } + + case "tool": { + const toolExists = toolMap.get(currentPart.id) + + if (toolExists) { + if (toolExists.value.status === "params-start" && currentPart.value.status === "params-streaming") { + toolExists.value = currentPart.value + } else if (toolExists.value.status === "params-streaming") { + if (currentPart.value.status === "params-streaming") { + toolExists.value.params += currentPart.value.params + } else if (currentPart.value.status === "params-done") { + toolExists.value = currentPart.value + } + } else if (toolExists.value.status === "params-done") { + if (currentPart.value.status === "result-done" || currentPart.value.status === "result-error") { + toolExists.value = currentPart.value + toolMap.delete(currentPart.id) + } + } + } else { + toolMap.set(currentPart.id, currentPart) + parts.push(currentPart) + } + + break + } + + default: { + parts.push(currentPart) + break + } + } + } + + return parts as Array> +}) + +export const accumulateParts = Function.dual< + >( + parts: Array> + ) => ( + accumulatedParts: Array> + ) => Effect.Effect< + Array>, + AiError.AiError, + IdGenerator.IdGenerator + >, + >( + accumulatedParts: Array>, + parts: Array> + ) => Effect.Effect< + Array>, + AiError.AiError, + IdGenerator.IdGenerator + > +>( + 2, + Effect.fnUntraced(function*>( + accumulatedParts: Array>, + parts: Array> + ) { + const toolMap = new Map< + string, + { + name: string + params?: string | unknown + providerExecuted: boolean + providerName?: string | undefined + } + >() + + const idGenerator = yield* IdGenerator.IdGenerator + + const streamAccumulatedParts = yield* Effect.forEach(parts, (part) => + Effect.gen(function*() { + switch (part.type) { + case "text": { + const id = yield* idGenerator.generateId() + return makePart("text-accumulated", { + status: "done", + text: part.text, + metadata: part.metadata, + id + }) + } + case "reasoning": { + const id = yield* idGenerator.generateId() + return makePart("reasoning-accumulated", { + status: "done", + text: part.text, + metadata: part.metadata, + id + }) + } + case "tool-call": { + toolMap.set(part.id, { + name: part.name, + params: part.params, + providerExecuted: part.providerExecuted, + ...(part.providerName ? ({ providerName: part.providerName }) : ({})) + }) + + return makePart("tool", { + id: part.id, + name: part.name, + providerExecuted: part.providerExecuted, + ...(part.providerName ? ({ providerName: part.providerName }) : ({})), + value: { + status: "params-done", + params: part.params as any + }, + metadata: part.metadata + }) + } + case "tool-result": { + const value = toolMap.get(part.id)! + + return makePart("tool", { + id: part.id, + name: part.name, + providerExecuted: part.providerExecuted, + ...(part.providerName ? ({ providerName: part.providerName }) : ({})), + value: { + status: part.isFailure ? "result-error" : "result-done", + params: value.params as any, + result: part.result, + encodedResult: part.encodedResult + }, + metadata: part.metadata + }) + } + + default: + return part + } + })) + + return yield* mergeAccumulatedParts([ + ...accumulatedParts, + ...(streamAccumulatedParts as Array>) + ]) + }) +) + +export const accumulateStreamParts = Function.dual< + >( + streamParts: Array> + ) => ( + accumulatedParts: Array> + ) => Effect.Effect>, AiError.AiError>, + >( + accumulatedParts: Array>, + streamParts: Array> + ) => Effect.Effect>, AiError.AiError> +>( + 2, + Effect.fnUntraced(function*>( + accumulatedParts: Array>, + streamParts: Array> + ) { + const toolMap = new Map< + string, + { + name: string + params?: string | unknown + providerExecuted: boolean + providerName?: string | undefined + } + >() + + const streamAccumulatedParts = streamParts + .filter( + (part) => + !( + part.type === "tool-params-end" + ) + ) + .map((part): AccumulatedPart => { + switch (part.type) { + case "text-start": { + return makePart("text-accumulated", { + status: "streaming", + text: "", + metadata: part.metadata, + id: part.id + }) + } + case "text-delta": { + return makePart("text-accumulated", { + status: "streaming", + text: part.delta, + metadata: part.metadata, + id: part.id + }) + } + case "text-end": { + return makePart("text-accumulated", { + status: "done", + text: "", + metadata: part.metadata, + id: part.id + }) + } + case "reasoning-start": { + return makePart("reasoning-accumulated", { + status: "streaming", + text: "", + metadata: part.metadata, + id: part.id + }) + } + case "reasoning-delta": { + return makePart("reasoning-accumulated", { + status: "streaming", + text: part.delta, + metadata: part.metadata, + id: part.id + }) + } + case "reasoning-end": { + return makePart("reasoning-accumulated", { + status: "done", + text: "", + metadata: part.metadata, + id: part.id + }) + } + case "tool-call": { + toolMap.set(part.id, { + name: part.name, + params: part.params, + providerExecuted: part.providerExecuted, + ...(part.providerName ? ({ providerName: part.providerName }) : ({})) + }) + + return makePart("tool", { + id: part.id, + name: part.name, + providerExecuted: part.providerExecuted, + ...(part.providerName ? ({ providerName: part.providerName }) : ({})), + value: { + status: "params-done", + params: part.params as any + }, + metadata: part.metadata + }) + } + + case "tool-result": { + const value = toolMap.get(part.id)! + + return makePart("tool", { + id: part.id, + name: part.name, + providerExecuted: part.providerExecuted, + ...(part.providerName ? ({ providerName: part.providerName }) : ({})), + value: { + status: part.isFailure ? "result-error" : "result-done", + params: value.params as any, + result: part.result, + encodedResult: part.encodedResult + }, + metadata: part.metadata + }) + } + + case "tool-params-start": { + toolMap.set(part.id, { + name: part.name, + providerExecuted: part.providerExecuted, + ...(part.providerName ? ({ providerName: part.providerName }) : ({})) + }) + + return makePart("tool", { + id: part.id, + name: part.name, + providerExecuted: part.providerExecuted, + ...(part.providerName ? ({ providerName: part.providerName }) : ({})), + value: { + status: "params-start" + }, + metadata: part.metadata + }) + } + + case "tool-params-delta": { + const value = toolMap.get(part.id)! + + toolMap.set(part.id, { + name: value.name, + params: part.delta, + providerExecuted: value.providerExecuted + }) + + return makePart("tool", { + id: part.id, + name: value.name, + providerExecuted: value.providerExecuted, + ...(value.providerName ? ({ providerName: value.providerName }) : ({})), + value: { + status: "params-streaming", + params: part.delta + }, + + metadata: part.metadata + }) + } + + default: + return part + } + }) + + return yield* mergeAccumulatedParts([ + ...accumulatedParts, + ...(streamAccumulatedParts as Array>) + ]) + }) +) + // ============================================================================= // Base Part // ============================================================================= @@ -632,6 +1128,102 @@ export const TextPart: Schema.Schema = Schema.Struct( */ export const textPart = (params: ConstructorParams): TextPart => makePart("text", params) +// ============================================================================= +// Text Accumulated Part +// ============================================================================= + +/** + * Response part representing plain accumulated text content, generally from stream text (start, delta, and end) part. + * + * @example + * ```ts + * import { Response } from "@effect/ai" + * + * const textAccumulatedPart: Response.TextAccumulatedPart = Response.makePart("text-accumulated", { + * text: "The answer to your question is 42.", + * status: "done" + * }) + * ``` + * + * @since 1.0.0 + * @category Models + */ +export interface TextAccumulatedPart extends BasePart<"text-accumulated", TextAccumulatedPartMetadata> { + /** + * Unique identifier for this text accumulated part. + */ + readonly id: string + /** + * The text content. + */ + readonly text: string + /** + * The text content status. + */ + readonly status: "done" | "streaming" +} + +/** + * Encoded representation of text accumulated parts for serialization. + * + * @since 1.0.0 + * @category Models + */ +export interface TextAccumulatedPartEncoded extends BasePartEncoded<"text-accumulated", TextAccumulatedPartMetadata> { + /** + * Unique identifier for this text accumulated part. + */ + readonly id: string + /** + * The text content. + */ + readonly text: string + /** + * The text content status. + */ + readonly status: "done" | "streaming" +} + +/** + * Represents provider-specific metadata that can be associated with a + * `TextAccumulatedPart` through module augmentation. + * + * @since 1.0.0 + * @category ProviderOptions + */ +export interface TextAccumulatedPartMetadata extends ProviderMetadata {} + +/** + * Schema for validation and encoding of text accumulated parts. + * + * @since 1.0.0 + * @category Schemas + */ +export const TextAccumulatedPart: Schema.Schema< + TextAccumulatedPart, + TextAccumulatedPartEncoded +> = Schema.Struct({ + id: Schema.String, + type: Schema.Literal("text-accumulated"), + text: Schema.String, + status: Schema.Literal("done", "streaming"), + metadata: Schema.optionalWith(ProviderMetadata, { + default: constEmptyObject + }) +}).pipe( + Schema.attachPropertySignature(PartTypeId, PartTypeId), + Schema.annotations({ identifier: "TextAccumulatedPart" }) +) + +/** + * Constructs a new text accumulated part. + * + * @since 1.0.0 + * @category Constructors + */ +export const textAccumulatedPart = (params: ConstructorParams): TextAccumulatedPart => + makePart("text-accumulated", params) + // ============================================================================= // Text Start Part // ============================================================================= @@ -906,6 +1498,105 @@ export const ReasoningPart: Schema.Schema = */ export const reasoningPart = (params: ConstructorParams): ReasoningPart => makePart("reasoning", params) +// ============================================================================= +// Reasoning Accumulated Part +// ============================================================================= + +/** + * Response part representing plain accumulated reasoning content, generally from stream reasoning (start, delta, and end) part. + * + * @example + * ```ts + * import { Response } from "@effect/ai" + * + * const reasoningAccumulatedPart: Response.ReasoningAccumulatedPart = Response.makePart("reasoning-accumulated", { + * text: "The answer to your question is 42.", + * status: "done" + * }) + * ``` + * + * @since 1.0.0 + * @category Models + */ +export interface ReasoningAccumulatedPart extends BasePart<"reasoning-accumulated", ReasoningAccumulatedPartMetadata> { + /** + * Unique identifier for this reasoning accumulated part. + */ + readonly id: string + /** + * The reasoning content. + */ + readonly text: string + /** + * The reasoning content status. + */ + readonly status: "done" | "streaming" +} + +/** + * Encoded representation of reasoning accumulated parts for serialization. + * + * @since 1.0.0 + * @category Models + */ +export interface ReasoningAccumulatedPartEncoded + extends BasePartEncoded<"reasoning-accumulated", ReasoningAccumulatedPartMetadata> +{ + /** + * Unique identifier for this reasoning accumulated part. + */ + readonly id: string + /** + * The reasoning content. + */ + readonly text: string + /** + * The reasoning content status. + */ + readonly status: "done" | "streaming" +} + +/** + * Represents provider-specific metadata that can be associated with a + * `ReasoningAccumulatedPart` through module augmentation. + * + * @since 1.0.0 + * @category ProviderOptions + */ +export interface ReasoningAccumulatedPartMetadata extends ProviderMetadata {} + +/** + * Schema for validation and encoding of text accumulated parts. + * + * @since 1.0.0 + * @category Schemas + */ +export const ReasoningAccumulatedPart: Schema.Schema< + ReasoningAccumulatedPart, + ReasoningAccumulatedPartEncoded +> = Schema.Struct({ + id: Schema.String, + type: Schema.Literal("reasoning-accumulated"), + text: Schema.String, + status: Schema.Literal("done", "streaming"), + metadata: Schema.optionalWith(ProviderMetadata, { + default: constEmptyObject + }) +}).pipe( + Schema.attachPropertySignature(PartTypeId, PartTypeId), + Schema.annotations({ identifier: "ReasoningAccumulatedPart" }) +) + +/** + * Constructs a new reasoning accumulated part. + * + * @since 1.0.0 + * @category Constructors + */ +export const reasoningAccumulatedPart = ( + params: ConstructorParams +): ReasoningAccumulatedPart => makePart("reasoning-accumulated", params) + // ============================================================================= // Reasoning Start Part // ============================================================================= @@ -1762,6 +2453,565 @@ export const toolResultPart = < } ? ToolResultPart : never => makePart("tool-result", params) as any +// ============================================================================= +// Tool Part +// ============================================================================= + +/** + * Response part representing the result of a tool. + * + * @example + * ```ts + * import { Either } from "effect" + * import { Response } from "@effect/ai" + * + * interface WeatherParams { + * location: number + * } + * + * interface WeatherData { + * temperature: number + * condition: string + * humidity: number + * } + * + * const toolPart: Response.ToolPart< + * "get_weather", + * WeatherParams, + * WeatherData, + * never + * > = Response.toolPart({ + * id: "call_123", + * name: "get_weather", + * value: { + * status: "result-done", + * params: { + * location: 500, + * }, + * result: { + * temperature: 22, + * condition: "sunny", + * humidity: 65 + * }, + * encodedResult: { + * temperature: 22, + * condition: "sunny", + * humidity: 65 + * }, + * } + * providerExecuted: false + * }) + * ``` + * + * @since 1.0.0 + * @category Models + */ +export interface ToolPart extends BasePart<"tool", ToolPartMetadata> { + /** + * Unique identifier matching the original tool call. + */ + readonly id: string + /** + * Name of the tool being called, which corresponds to the name of the tool + * in the `Toolkit` included with the request. + */ + readonly name: Name + /** + * Optional provider-specific name for the tool, which can be useful when the + * name of the tool in the `Toolkit` and the name of the tool used by the + * model are different. + * + * This is usually happens only with provider-defined tools which require a + * user-space handler. + */ + readonly providerName?: string | undefined + /** + * Whether the tool was executed by the provider (true) or framework (false). + */ + readonly providerExecuted: boolean + /** + * the tool value depedning on the status of the tool + */ + readonly value: + | { + /** + * the status when params are started to streaming in + */ + readonly status: "params-start" + } + | { + /** + * the status when params are streaming in + */ + readonly status: "params-streaming" + /** + * Parameters to pass to the tool. + */ + readonly params: string + } + | { + /** + * the status when params are malformed + */ + readonly status: "params-malformed" + /** + * Parameters to pass to the tool. + */ + readonly params: string + } + | { + /** + * the status when params are done streaming + */ + readonly status: "params-done" + /** + * Parameters passed to the tool. + */ + readonly params: Params + } + | { + /** + * the status when the tool returned an error result + */ + readonly status: "result-error" + /** + * Parameters passed to the tool. + */ + readonly params: Params + /** + * The failure result returned by the tool execution. + */ + readonly result: Failure + /** + * The encoded result for serialization purposes. + */ + readonly encodedResult: unknown + } + | { + /** + * the status when the tool call result is returned + */ + readonly status: "result-done" + /** + * Parameters passed to the tool. + */ + readonly params: Params + /** + * The result returned by the tool execution. + */ + readonly result: Success + /** + * The encoded result for serialization purposes. + */ + readonly encodedResult: unknown + } +} + +/** + * Encoded representation of tool result parts for serialization. + * + * @since 1.0.0 + * @category Models + */ +export interface ToolPartEncoded extends BasePartEncoded<"tool", ToolPartMetadata> { + /** + * Unique identifier matching the original tool call. + */ + readonly id: string + /** + * Name of the tool being called, which corresponds to the name of the tool + * in the `Toolkit` included with the request. + */ + readonly name: string + /** + * Optional provider-specific name for the tool, which can be useful when the + * name of the tool in the `Toolkit` and the name of the tool used by the + * model are different. + * + * This is usually happens only with provider-defined tools which require a + * user-space handler. + */ + readonly providerName?: string | undefined + /** + * Whether the tool was executed by the provider (true) or framework (false). + */ + readonly providerExecuted?: boolean | undefined + /** + * the tool value depedning on the status of the tool + */ + readonly value: + | { + /** + * the status when params are started to streaming in + */ + readonly status: "params-start" + } + | { + /** + * the status when params are streaming in + */ + readonly status: "params-streaming" + /** + * Parameters to pass to the tool. + */ + readonly params: string + } + | { + /** + * the status when params are malformed + */ + readonly status: "params-malformed" + /** + * Parameters to pass to the tool. + */ + readonly params: string + } + | { + /** + * the status when params are done streaming + */ + readonly status: "params-done" + /** + * Parameters passed to the tool. + */ + readonly params: unknown + } + | { + /** + * the status when the tool returned an error result + */ + readonly status: "result-error" + /** + * Parameters passed to the tool. + */ + readonly params: unknown + /** + * The failure result returned by the tool execution. + */ + readonly result: unknown + } + | { + /** + * the status when the tool call result is returned + */ + readonly status: "result-done" + /** + * Parameters passed to the tool. + */ + readonly params: unknown + /** + * The result returned by the tool execution. + */ + readonly result: unknown + } +} + +/** + * Represents provider-specific metadata that can be associated with a + * `ToolResultPart` through module augmentation. + * + * @since 1.0.0 + * @category ProviderOptions + */ +export interface ToolPartMetadata extends ProviderMetadata {} + +/** + * Creates a Schema for tool parts with specific tool name, param type, result type, and failure type. + * + * @since 1.0.0 + * @category Schemas + */ +export const ToolPart = < + const Name extends string, + Params extends Schema.Struct.Fields, + Success extends Schema.Schema.Any, + Failure extends Schema.Schema.All +>( + name: Name, + params: Schema.Struct, + success: Success, + failure: Failure +): Schema.Schema< + ToolPart, Schema.Schema.Type, Schema.Schema.Type>, + ToolPartEncoded +> => { + const Base = Schema.Struct({ + id: Schema.String, + type: Schema.Literal("tool"), + providerName: Schema.optional(Schema.String) + }) + const Encoded = Schema.Struct({ + ...Base.fields, + name: Schema.String, + value: Schema.Union( + Schema.Struct({ + status: Schema.Literal("params-start") + }), + Schema.Struct({ + status: Schema.Literal("params-streaming"), + params: Schema.String + }), + Schema.Struct({ + status: Schema.Literal("params-malformed"), + params: Schema.String + }), + Schema.Struct({ + status: Schema.Literal("params-done"), + params: Schema.encodedSchema(params) + }), + Schema.Struct({ + status: Schema.Literal("result-error"), + params: Schema.encodedSchema(params), + result: Schema.encodedSchema(failure as any) + }), + Schema.Struct({ + status: Schema.Literal("result-done"), + params: Schema.encodedSchema(params), + result: Schema.encodedSchema(success) + }) + ), + providerExecuted: Schema.optional(Schema.Boolean), + metadata: Schema.optional(ProviderMetadata) + }) + const Decoded = Schema.Struct({ + ...Base.fields, + [PartTypeId]: Schema.Literal(PartTypeId), + name: Schema.Literal(name), + value: Schema.Union( + Schema.Struct({ + status: Schema.Literal("params-start") + }), + Schema.Struct({ + status: Schema.Literal("params-streaming"), + params: Schema.String + }), + Schema.Struct({ + status: Schema.Literal("params-malformed"), + params: Schema.String + }), + Schema.Struct({ + status: Schema.Literal("params-done"), + params: Schema.typeSchema(params) + }), + Schema.Struct({ + status: Schema.Literal("result-error"), + params: Schema.typeSchema(params), + result: Schema.typeSchema(failure as any), + encodedResult: Schema.encodedSchema(failure as any) + }), + Schema.Struct({ + status: Schema.Literal("result-done"), + params: Schema.typeSchema(params), + result: Schema.typeSchema(success), + encodedResult: Schema.encodedSchema(success) + }) + ), + providerExecuted: Schema.Boolean, + metadata: ProviderMetadata + }) + + const decodeResultSucces = ParseResult.decode(success) + const encodeResultSucces = ParseResult.encode(success) + const decodeResultFailure = ParseResult.decode(failure as any) + const encodeResultFailure = ParseResult.encode(failure as any) + const decodeParams = ParseResult.decode(params) + const encodeParams = ParseResult.encode(params) + + type Encoded = Schema.Schema.Type + type Decoded = Schema.Schema.Type + + return Schema.transformOrFail(Encoded, Decoded, { + strict: true, + decode: Effect.fnUntraced(function*(encoded) { + if (encoded.value.status === "result-error") { + const decodedResultFailure = yield* decodeResultFailure( + encoded.value.result + ) + const decodedParams = yield* decodeParams(encoded.value.params) + + return { + ...encoded, + [PartTypeId]: PartTypeId, + name: encoded.name as Name, + value: { + status: "result-error", + params: decodedParams, + result: decodedResultFailure as Schema.Schema.Type, + encodedResult: encoded.value.result + }, + metadata: encoded.metadata ?? {}, + providerExecuted: encoded.providerExecuted ?? false + } satisfies Decoded + } else if (encoded.value.status === "result-done") { + const decodedResultSucces = yield* decodeResultSucces( + encoded.value.result + ) + const decodedParams = yield* decodeParams(encoded.value.params) + + return { + ...encoded, + [PartTypeId]: PartTypeId, + name: encoded.name as Name, + value: { + status: "result-done" as const, + params: decodedParams, + result: decodedResultSucces as Schema.Schema.Type, + encodedResult: encoded.value.result + }, + metadata: encoded.metadata ?? {}, + providerExecuted: encoded.providerExecuted ?? false + } satisfies Decoded + } else if (encoded.value.status === "params-done") { + const decodedParams = yield* decodeParams(encoded.value.params) + + return { + ...encoded, + [PartTypeId]: PartTypeId, + name: encoded.name as Name, + value: { + status: "params-done" as const, + params: decodedParams + }, + metadata: encoded.metadata ?? {}, + providerExecuted: encoded.providerExecuted ?? false + } satisfies Decoded + } else if (encoded.value.status === "params-start") { + return { + ...encoded, + [PartTypeId]: PartTypeId, + name: encoded.name as Name, + value: { + status: "params-start" + }, + metadata: encoded.metadata ?? {}, + providerExecuted: encoded.providerExecuted ?? false + } satisfies Decoded + } else { + return { + ...encoded, + [PartTypeId]: PartTypeId, + name: encoded.name as Name, + value: encoded.value, + metadata: encoded.metadata ?? {}, + providerExecuted: encoded.providerExecuted ?? false + } satisfies Decoded + } + }), + encode: Effect.fnUntraced(function*(decoded) { + if (decoded.value.status === "result-error") { + const encodedResultFailure = yield* encodeResultFailure( + decoded.value.result + ) + const encodedParams = yield* encodeParams(decoded.value.params) + + return { + ...decoded, + value: { + status: "result-error" as const, + result: encodedResultFailure, + params: encodedParams + }, + ...(decoded.metadata ?? {}), + ...(decoded.providerName + ? { providerName: decoded.providerName } + : {}), + ...(decoded.providerExecuted + ? { providerExecuted: true } + : {}) + } satisfies Encoded + } else if (decoded.value.status === "result-done") { + const encodedResultSuccess = yield* encodeResultSucces( + decoded.value.result + ) + const encodedParams = yield* encodeParams(decoded.value.params) + + return { + ...decoded, + value: { + status: "result-done" as const, + result: encodedResultSuccess, + params: encodedParams + }, + ...(decoded.metadata ?? {}), + ...(decoded.providerName + ? { providerName: decoded.providerName } + : {}), + ...(decoded.providerExecuted + ? { providerExecuted: true } + : {}) + } satisfies Encoded + } else if (decoded.value.status === "params-done") { + const encodedParams = yield* encodeParams(decoded.value.params) + + return { + ...decoded, + value: { + status: "params-done" as const, + params: encodedParams + }, + ...(decoded.metadata ?? {}), + ...(decoded.providerName + ? { providerName: decoded.providerName } + : {}), + ...(decoded.providerExecuted + ? { providerExecuted: true } + : {}) + } satisfies Encoded + } else { + return { + ...decoded, + value: decoded.value, + ...(decoded.metadata ?? {}), + ...(decoded.providerName + ? { providerName: decoded.providerName } + : {}), + ...(decoded.providerExecuted + ? { providerExecuted: true } + : {}) + } satisfies Encoded + } + }) + }) as any +} + +/** + * Constructs a new tool part. + * + * @since 1.0.0 + * @category Constructors + */ +export const toolPart = < + const Params extends ConstructorParams> +>( + params: Params +): Params extends { + readonly name: infer Name extends string + readonly value: { + readonly status: "params-done" + readonly params: infer Params + } +} ? ToolPart + : Params extends { + readonly name: infer Name extends string + readonly value: { + readonly status: "result-error" + readonly params: infer Params + readonly result: infer Failure + } + } ? ToolPart + : Params extends { + readonly name: infer Name extends string + readonly value: { + readonly status: "result-done" + readonly params: infer Params + readonly result: infer Success + } + } ? ToolPart + : Params extends { + readonly name: infer Name extends string + } ? ToolPart : + never => makePart("tool", params) as any + // ============================================================================= // File Part // ============================================================================= diff --git a/packages/ai/ai/test/Response.test.ts b/packages/ai/ai/test/Response.test.ts new file mode 100644 index 00000000000..e258417c374 --- /dev/null +++ b/packages/ai/ai/test/Response.test.ts @@ -0,0 +1,2008 @@ +import * as IdGenerator from "@effect/ai/IdGenerator" +import * as Response from "@effect/ai/Response" +import { assert, describe, it } from "@effect/vitest" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" + +describe("Response", () => { + describe("mergeAccumulatedParts", () => { + it.effect( + "should handle complete text and response deltas", + Effect.fnUntraced(function*() { + const parts = [ + Response.textAccumulatedPart({ + id: "1", + status: "streaming", + text: "Hello" + }), + Response.textAccumulatedPart({ + id: "1", + status: "streaming", + text: ", " + }), + Response.textAccumulatedPart({ + id: "1", + status: "streaming", + text: "World!" + }), + Response.textAccumulatedPart({ + id: "1", + status: "done", + text: "" + }), + Response.reasoningAccumulatedPart({ + id: "2", + status: "streaming", + text: "I " + }), + Response.reasoningAccumulatedPart({ + id: "2", + status: "streaming", + text: "am " + }), + Response.reasoningAccumulatedPart({ + id: "2", + status: "streaming", + text: "thinking!" + }), + Response.reasoningAccumulatedPart({ + id: "2", + status: "done", + text: "" + }) + ] + const accumulatedParts = yield* Response.mergeAccumulatedParts(parts) + const expected = [ + Response.textAccumulatedPart({ + id: "1", + text: "Hello, World!", + status: "done" + }), + Response.reasoningAccumulatedPart({ + id: "2", + text: "I am thinking!", + status: "done" + }) + ] + assert.deepStrictEqual(accumulatedParts, expected) + }) + ) + + it.effect( + "should merge two fully streamed text parts, both ending with 'done' status", + Effect.fnUntraced(function*() { + const parts = [ + Response.textAccumulatedPart({ + id: "1", + status: "streaming", + text: "Hello" + }), + Response.textAccumulatedPart({ + id: "1", + status: "streaming", + text: ", " + }), + Response.textAccumulatedPart({ + id: "2", + status: "streaming", + text: "Hello" + }), + Response.textAccumulatedPart({ + id: "2", + status: "streaming", + text: ", " + }), + Response.textAccumulatedPart({ + id: "2", + status: "done", + text: "World!" + }), + Response.textAccumulatedPart({ + id: "1", + status: "done", + text: "World!" + }) + ] + const accumulatedParts = yield* Response.mergeAccumulatedParts(parts) + const expected = [ + Response.textAccumulatedPart({ + id: "1", + text: "Hello, World!", + status: "done" + }), + Response.textAccumulatedPart({ + id: "2", + text: "Hello, World!", + status: "done" + }) + ] + assert.deepStrictEqual(accumulatedParts, expected) + }) + ) + + it.effect( + "should merge interleaved text parts where one stream is 'done' and the other is still 'streaming'", + Effect.fnUntraced(function*() { + const parts = [ + Response.textAccumulatedPart({ + id: "1", + status: "streaming", + text: "Hello" + }), + Response.textAccumulatedPart({ + id: "1", + status: "streaming", + text: ", " + }), + Response.textAccumulatedPart({ + id: "2", + status: "streaming", + text: "Hello" + }), + Response.textAccumulatedPart({ + id: "2", + status: "streaming", + text: ", " + }), + Response.textAccumulatedPart({ + id: "2", + status: "done", + text: "World!" + }), + Response.textAccumulatedPart({ + id: "1", + status: "streaming", + text: "World!" + }) + ] + const accumulatedParts = yield* Response.mergeAccumulatedParts(parts) + const expected = [ + Response.textAccumulatedPart({ + id: "1", + text: "Hello, World!", + status: "streaming" + }), + Response.textAccumulatedPart({ + id: "2", + text: "Hello, World!", + status: "done" + }) + ] + assert.deepStrictEqual(accumulatedParts, expected) + }) + ) + + it.effect( + "should merge interleaved text parts, preserving correct order when part sequence is non-consecutive but both streams are 'done'", + Effect.fnUntraced(function*() { + const parts = [ + Response.textAccumulatedPart({ + id: "1", + status: "streaming", + text: "Hello" + }), + Response.textAccumulatedPart({ + id: "2", + status: "streaming", + text: "Hello" + }), + Response.textAccumulatedPart({ + id: "2", + status: "streaming", + text: ", " + }), + Response.textAccumulatedPart({ + id: "2", + status: "done", + text: "World!" + }), + Response.textAccumulatedPart({ + id: "1", + status: "streaming", + text: ", " + }), + Response.textAccumulatedPart({ + id: "1", + status: "done", + text: "World!" + }) + ] + const accumulatedParts = yield* Response.mergeAccumulatedParts(parts) + const expected = [ + Response.textAccumulatedPart({ + id: "1", + text: "Hello, World!", + status: "done" + }), + Response.textAccumulatedPart({ + id: "2", + text: "Hello, World!", + status: "done" + }) + ] + assert.deepStrictEqual(accumulatedParts, expected) + }) + ) + + it.effect( + "should merge interleaved text parts, preserving correct order when part sequence is non-consecutive, and one stream is still 'streaming'", + Effect.fnUntraced(function*() { + const parts = [ + Response.textAccumulatedPart({ + id: "1", + status: "streaming", + text: "Hello" + }), + Response.textAccumulatedPart({ + id: "2", + status: "streaming", + text: "Hello" + }), + Response.textAccumulatedPart({ + id: "2", + status: "streaming", + text: ", " + }), + Response.textAccumulatedPart({ + id: "2", + status: "done", + text: "World!" + }), + Response.textAccumulatedPart({ + id: "1", + status: "streaming", + text: ", " + }), + Response.textAccumulatedPart({ + id: "1", + status: "streaming", + text: "World!" + }) + ] + const accumulatedParts = yield* Response.mergeAccumulatedParts(parts) + const expected = [ + Response.textAccumulatedPart({ + id: "1", + text: "Hello, World!", + status: "streaming" + }), + Response.textAccumulatedPart({ + id: "2", + text: "Hello, World!", + status: "done" + }) + ] + assert.deepStrictEqual(accumulatedParts, expected) + }) + ) + + it.effect( + "should merge multiple small, highly interleaved text parts from two streams, both resulting in 'done' status", + Effect.fnUntraced(function*() { + const parts = [ + Response.textAccumulatedPart({ + id: "1", + status: "streaming", + text: "Hel" + }), + Response.textAccumulatedPart({ + id: "2", + status: "streaming", + text: "Hel" + }), + Response.textAccumulatedPart({ + id: "2", + status: "streaming", + text: "lo," + }), + Response.textAccumulatedPart({ + id: "2", + status: "streaming", + text: " Wo" + }), + Response.textAccumulatedPart({ + id: "1", + status: "streaming", + text: "lo, " + }), + Response.textAccumulatedPart({ + id: "2", + status: "streaming", + text: "rld" + }), + Response.textAccumulatedPart({ + id: "1", + status: "streaming", + text: "Worl" + }), + Response.textAccumulatedPart({ + id: "1", + status: "done", + text: "d!" + }), + Response.textAccumulatedPart({ + id: "2", + status: "done", + text: "!" + }) + ] + const accumulatedParts = yield* Response.mergeAccumulatedParts(parts) + const expected = [ + Response.textAccumulatedPart({ + id: "1", + text: "Hello, World!", + status: "done" + }), + Response.textAccumulatedPart({ + id: "2", + text: "Hello, World!", + status: "done" + }) + ] + assert.deepStrictEqual(accumulatedParts, expected) + }) + ) + + it.effect( + "should merge multiple small, highly interleaved text parts from two streams, with one stream finishing as 'done' and the other as 'streaming'", + Effect.fnUntraced(function*() { + const parts = [ + Response.textAccumulatedPart({ + id: "1", + status: "streaming", + text: "Hel" + }), + Response.textAccumulatedPart({ + id: "2", + status: "streaming", + text: "Hel" + }), + Response.textAccumulatedPart({ + id: "2", + status: "streaming", + text: "lo," + }), + Response.textAccumulatedPart({ + id: "2", + status: "streaming", + text: " Wo" + }), + Response.textAccumulatedPart({ + id: "2", + status: "streaming", + text: "rld" + }), + Response.textAccumulatedPart({ + id: "1", + status: "streaming", + text: "lo, " + }), + Response.textAccumulatedPart({ + id: "2", + status: "done", + text: "!" + }), + Response.textAccumulatedPart({ + id: "1", + status: "streaming", + text: "Worl" + }), + Response.textAccumulatedPart({ + id: "1", + status: "streaming", + text: "d!" + }) + ] + const accumulatedParts = yield* Response.mergeAccumulatedParts(parts) + const expected = [ + Response.textAccumulatedPart({ + id: "1", + text: "Hello, World!", + status: "streaming" + }), + Response.textAccumulatedPart({ + id: "2", + text: "Hello, World!", + status: "done" + }) + ] + assert.deepStrictEqual(accumulatedParts, expected) + }) + ) + + it.effect( + "should merge two fully streamed reasoned parts, both ending with 'done' status", + Effect.fnUntraced(function*() { + const parts = [ + Response.reasoningAccumulatedPart({ + id: "1", + status: "streaming", + text: "Hello" + }), + Response.reasoningAccumulatedPart({ + id: "1", + status: "streaming", + text: ", " + }), + Response.reasoningAccumulatedPart({ + id: "2", + status: "streaming", + text: "Hello" + }), + Response.reasoningAccumulatedPart({ + id: "2", + status: "streaming", + text: ", " + }), + Response.reasoningAccumulatedPart({ + id: "2", + status: "done", + text: "World!" + }), + Response.reasoningAccumulatedPart({ + id: "1", + status: "done", + text: "World!" + }) + ] + const accumulatedParts = yield* Response.mergeAccumulatedParts(parts) + const expected = [ + Response.reasoningAccumulatedPart({ + id: "1", + text: "Hello, World!", + status: "done" + }), + Response.reasoningAccumulatedPart({ + id: "2", + text: "Hello, World!", + status: "done" + }) + ] + assert.deepStrictEqual(accumulatedParts, expected) + }) + ) + + it.effect( + "should merge interleaved reasoning parts where one stream is 'done' and the other is still 'streaming'", + Effect.fnUntraced(function*() { + const parts = [ + Response.reasoningAccumulatedPart({ + id: "1", + status: "streaming", + text: "Hello" + }), + Response.reasoningAccumulatedPart({ + id: "1", + status: "streaming", + text: ", " + }), + Response.reasoningAccumulatedPart({ + id: "2", + status: "streaming", + text: "Hello" + }), + Response.reasoningAccumulatedPart({ + id: "2", + status: "streaming", + text: ", " + }), + Response.reasoningAccumulatedPart({ + id: "2", + status: "done", + text: "World!" + }), + Response.reasoningAccumulatedPart({ + id: "1", + status: "streaming", + text: "World!" + }) + ] + const accumulatedParts = yield* Response.mergeAccumulatedParts(parts) + const expected = [ + Response.reasoningAccumulatedPart({ + id: "1", + text: "Hello, World!", + status: "streaming" + }), + Response.reasoningAccumulatedPart({ + id: "2", + text: "Hello, World!", + status: "done" + }) + ] + assert.deepStrictEqual(accumulatedParts, expected) + }) + ) + + it.effect( + "should merge interleaved reasoning parts, preserving correct order when part sequence is non-consecutive but both streams are 'done'", + Effect.fnUntraced(function*() { + const parts = [ + Response.reasoningAccumulatedPart({ + id: "1", + status: "streaming", + text: "Hello" + }), + Response.reasoningAccumulatedPart({ + id: "2", + status: "streaming", + text: "Hello" + }), + Response.reasoningAccumulatedPart({ + id: "2", + status: "streaming", + text: ", " + }), + Response.reasoningAccumulatedPart({ + id: "2", + status: "done", + text: "World!" + }), + Response.reasoningAccumulatedPart({ + id: "1", + status: "streaming", + text: ", " + }), + Response.reasoningAccumulatedPart({ + id: "1", + status: "done", + text: "World!" + }) + ] + const accumulatedParts = yield* Response.mergeAccumulatedParts(parts) + const expected = [ + Response.reasoningAccumulatedPart({ + id: "1", + text: "Hello, World!", + status: "done" + }), + Response.reasoningAccumulatedPart({ + id: "2", + text: "Hello, World!", + status: "done" + }) + ] + assert.deepStrictEqual(accumulatedParts, expected) + }) + ) + + it.effect( + "should merge interleaved reasoning parts, preserving correct order when part sequence is non-consecutive, and one stream is still 'streaming'", + Effect.fnUntraced(function*() { + const parts = [ + Response.reasoningAccumulatedPart({ + id: "1", + status: "streaming", + text: "Hello" + }), + Response.reasoningAccumulatedPart({ + id: "2", + status: "streaming", + text: "Hello" + }), + Response.reasoningAccumulatedPart({ + id: "2", + status: "streaming", + text: ", " + }), + Response.reasoningAccumulatedPart({ + id: "2", + status: "done", + text: "World!" + }), + Response.reasoningAccumulatedPart({ + id: "1", + status: "streaming", + text: ", " + }), + Response.reasoningAccumulatedPart({ + id: "1", + status: "streaming", + text: "World!" + }) + ] + const accumulatedParts = yield* Response.mergeAccumulatedParts(parts) + const expected = [ + Response.reasoningAccumulatedPart({ + id: "1", + text: "Hello, World!", + status: "streaming" + }), + Response.reasoningAccumulatedPart({ + id: "2", + text: "Hello, World!", + status: "done" + }) + ] + assert.deepStrictEqual(accumulatedParts, expected) + }) + ) + + it.effect( + "should merge multiple small, highly interleaved reasoning parts from two streams, both resulting in 'done' status", + Effect.fnUntraced(function*() { + const parts = [ + Response.reasoningAccumulatedPart({ + id: "1", + status: "streaming", + text: "Hel" + }), + Response.reasoningAccumulatedPart({ + id: "2", + status: "streaming", + text: "Hel" + }), + Response.reasoningAccumulatedPart({ + id: "2", + status: "streaming", + text: "lo," + }), + Response.reasoningAccumulatedPart({ + id: "2", + status: "streaming", + text: " Wo" + }), + Response.reasoningAccumulatedPart({ + id: "1", + status: "streaming", + text: "lo, " + }), + Response.reasoningAccumulatedPart({ + id: "2", + status: "streaming", + text: "rld" + }), + Response.reasoningAccumulatedPart({ + id: "1", + status: "streaming", + text: "Worl" + }), + Response.reasoningAccumulatedPart({ + id: "1", + status: "done", + text: "d!" + }), + Response.reasoningAccumulatedPart({ + id: "2", + status: "done", + text: "!" + }) + ] + const accumulatedParts = yield* Response.mergeAccumulatedParts(parts) + const expected = [ + Response.reasoningAccumulatedPart({ + id: "1", + text: "Hello, World!", + status: "done" + }), + Response.reasoningAccumulatedPart({ + id: "2", + text: "Hello, World!", + status: "done" + }) + ] + assert.deepStrictEqual(accumulatedParts, expected) + }) + ) + + it.effect( + "should merge multiple small, highly interleaved reasoning parts from two streams, with one stream finishing as 'done' and the other as 'streaming'", + Effect.fnUntraced(function*() { + const parts = [ + Response.reasoningAccumulatedPart({ + id: "1", + status: "streaming", + text: "Hel" + }), + Response.reasoningAccumulatedPart({ + id: "2", + status: "streaming", + text: "Hel" + }), + Response.reasoningAccumulatedPart({ + id: "2", + status: "streaming", + text: "lo," + }), + Response.reasoningAccumulatedPart({ + id: "2", + status: "streaming", + text: " Wo" + }), + Response.reasoningAccumulatedPart({ + id: "2", + status: "streaming", + text: "rld" + }), + Response.reasoningAccumulatedPart({ + id: "1", + status: "streaming", + text: "lo, " + }), + Response.reasoningAccumulatedPart({ + id: "2", + status: "done", + text: "!" + }), + Response.reasoningAccumulatedPart({ + id: "1", + status: "streaming", + text: "Worl" + }), + Response.reasoningAccumulatedPart({ + id: "1", + status: "streaming", + text: "d!" + }) + ] + const accumulatedParts = yield* Response.mergeAccumulatedParts(parts) + const expected = [ + Response.reasoningAccumulatedPart({ + id: "1", + text: "Hello, World!", + status: "streaming" + }), + Response.reasoningAccumulatedPart({ + id: "2", + text: "Hello, World!", + status: "done" + }) + ] + assert.deepStrictEqual(accumulatedParts, expected) + }) + ) + + it.effect( + "should handle incomplete text delta with status 'streaming'", + Effect.fnUntraced(function*() { + const parts = [ + Response.textAccumulatedPart({ + id: "1", + status: "streaming", + text: "Hello" + }), + Response.textAccumulatedPart({ + id: "1", + status: "streaming", + text: ", " + }), + Response.textAccumulatedPart({ + id: "1", + status: "streaming", + text: "World!" + }) + ] + const accumulatedParts = yield* Response.mergeAccumulatedParts(parts) + const expected = [ + Response.textAccumulatedPart({ + id: "1", + text: "Hello, World!", + status: "streaming" + }) + ] + assert.deepStrictEqual(accumulatedParts, expected) + }) + ) + + it.effect( + "should handle incomplete reasoning delta with status 'streaming'", + Effect.fnUntraced(function*() { + const parts = [ + Response.reasoningAccumulatedPart({ + id: "1", + status: "streaming", + text: "I " + }), + Response.reasoningAccumulatedPart({ + id: "1", + status: "streaming", + text: "am " + }), + Response.reasoningAccumulatedPart({ + id: "1", + status: "streaming", + text: "thinking!" + }) + ] + const accumulatedParts = yield* Response.mergeAccumulatedParts(parts) + const expected = [ + Response.reasoningAccumulatedPart({ + id: "1", + text: "I am thinking!", + status: "streaming" + }) + ] + assert.deepStrictEqual(accumulatedParts, expected) + }) + ) + + it.effect( + "should merge interleaved parameter streaming chunks for multiple concurrent tool calls, both reaching 'params-done' status", + Effect.fnUntraced(function*() { + const parts = [ + Response.toolPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + value: { + status: "params-start" + } + }), + Response.toolPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + value: { + status: "params-streaming", + params: "{location: '" + } + }), + Response.toolPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + value: { + status: "params-streaming", + params: "NYC" + } + }), + Response.toolPart({ + id: "2", + name: "getWeather", + providerExecuted: false, + value: { + status: "params-start" + } + }), + Response.toolPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + value: { + status: "params-streaming", + params: "'}" + } + }), + Response.toolPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + value: { + status: "params-done", + params: { + location: "NYC" + } + } + }), + Response.toolPart({ + id: "2", + name: "getWeather", + providerExecuted: false, + value: { + status: "params-streaming", + params: "{location: '" + } + }), + Response.toolPart({ + id: "2", + name: "getWeather", + providerExecuted: false, + value: { + status: "params-streaming", + params: "NYC" + } + }), + Response.toolPart({ + id: "2", + name: "getWeather", + providerExecuted: false, + value: { + status: "params-streaming", + params: "'}" + } + }), + Response.toolPart({ + id: "2", + name: "getWeather", + providerExecuted: false, + value: { + status: "params-done", + params: { + location: "NYC" + } + } + }) + ] as Array> + const accumulatedParts = yield* Response.mergeAccumulatedParts(parts) + const expected = [ + Response.toolPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + value: { + status: "params-done", + params: { + location: "NYC" + } + } + }), + Response.toolPart({ + id: "2", + name: "getWeather", + providerExecuted: false, + value: { + status: "params-done", + params: { + location: "NYC" + } + } + }) + ] as Array> + assert.deepStrictEqual(accumulatedParts, expected) + }) + ) + + it.effect( + "should handle complete tool call params", + Effect.fnUntraced(function*() { + const parts = [ + Response.toolPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + value: { + status: "params-start" + } + }), + Response.toolPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + value: { + status: "params-streaming", + params: "{location: '" + } + }), + Response.toolPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + value: { + status: "params-streaming", + params: "NYC" + } + }), + Response.toolPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + value: { + status: "params-streaming", + params: "'}" + } + }), + Response.toolPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + value: { + status: "params-done", + params: { + location: "NYC" + } + } + }) + ] as Array> + const accumulatedParts = yield* Response.mergeAccumulatedParts(parts) + const expected = [ + Response.toolPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + value: { + status: "params-done", + params: { + location: "NYC" + } + } + }) + ] as Array> + assert.deepStrictEqual(accumulatedParts, expected) + }) + ) + + it.effect( + "should handle incomplete tool call params", + Effect.fnUntraced(function*() { + const parts = [ + Response.toolPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + value: { + status: "params-start" + } + }), + Response.toolPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + value: { + status: "params-streaming", + params: "{location: '" + } + }), + Response.toolPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + value: { + status: "params-streaming", + params: "NYC" + } + }) + ] as Array> + const accumulatedParts = yield* Response.mergeAccumulatedParts(parts) + const expected = [ + Response.toolPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + value: { + status: "params-streaming", + params: "{location: 'NYC" + } + }) + ] as Array> + assert.deepStrictEqual(accumulatedParts, expected) + }) + ) + + it.effect( + "should handle complete tool call params and tool result", + Effect.fnUntraced(function*() { + const parts = [ + Response.toolPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + value: { + status: "params-start" + } + }), + Response.toolPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + value: { + status: "params-streaming", + params: "{location: '" + } + }), + Response.toolPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + value: { + status: "params-streaming", + params: "NYC" + } + }), + Response.toolPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + value: { + status: "params-streaming", + params: "'}" + } + }), + Response.toolPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + value: { + status: "params-done", + params: { + location: "NYC" + } + } + }), + Response.toolPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + value: { + status: "result-done", + params: { + location: "NYC" + }, + result: { + temperature: 12 + }, + encodedResult: { + temperature: 12 + } + } + }) + ] as Array> + const accumulatedParts = yield* Response.mergeAccumulatedParts(parts) + const expected = [ + Response.toolPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + value: { + status: "result-done", + params: { + location: "NYC" + }, + result: { + temperature: 12 + }, + encodedResult: { + temperature: 12 + } + } + }) + ] as Array> + assert.deepStrictEqual(accumulatedParts, expected) + }) + ) + + it.effect( + "should handle complete tool call and tool result error", + Effect.fnUntraced(function*() { + const parts = [ + Response.toolPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + value: { + status: "params-start" + } + }), + Response.toolPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + value: { + status: "params-streaming", + params: "{location: '" + } + }), + Response.toolPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + value: { + status: "params-streaming", + params: "NYC" + } + }), + Response.toolPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + value: { + status: "params-streaming", + params: "'}" + } + }), + Response.toolPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + value: { + status: "params-done", + params: { + location: "NYC" + } + } + }), + Response.toolPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + value: { + status: "result-error", + params: { + location: "NYC" + }, + result: { + message: "Unknown location" + }, + encodedResult: { + message: "Unknown location" + } + } + }) + ] as Array> + const accumulatedParts = yield* Response.mergeAccumulatedParts(parts) + const expected = [ + Response.toolPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + value: { + status: "result-error", + params: { + location: "NYC" + }, + result: { + message: "Unknown location" + }, + encodedResult: { + message: "Unknown location" + } + } + }) + ] as Array> + assert.deepStrictEqual(accumulatedParts, expected) + }) + ) + + it.effect( + "should return an empty accumulated parts if no parts need to be merged", + Effect.fnUntraced(function*() { + const parts: Array> = [] + const accumulatedParts = yield* Response.mergeAccumulatedParts(parts) + const expected: Array> = [] + assert.deepStrictEqual(accumulatedParts, expected) + }) + ) + }) + + describe("accumulateStreamParts", () => { + it.effect( + "should return a text part with status 'done' for complete text stream parts", + Effect.fnUntraced(function*() { + const parts = [ + Response.textStartPart({ + id: "1" + }), + Response.textDeltaPart({ + id: "1", + delta: "Hello" + }), + Response.textDeltaPart({ + id: "1", + delta: ", " + }), + Response.textDeltaPart({ + id: "1", + delta: "World!" + }), + Response.textEndPart({ + id: "1" + }) + ] + const accumulatedParts = yield* Response.accumulateStreamParts([], parts) + const expected = [ + Response.textAccumulatedPart({ + id: "1", + text: "Hello, World!", + status: "done" + }) + ] + assert.deepStrictEqual(accumulatedParts, expected) + }) + ) + + it.effect( + "should return a reasoning part with status 'done' for complete reasoning stream parts", + Effect.fnUntraced(function*() { + const parts = [ + Response.reasoningStartPart({ + id: "2" + }), + Response.reasoningDeltaPart({ + id: "2", + delta: "I " + }), + Response.reasoningDeltaPart({ + id: "2", + delta: "am " + }), + Response.reasoningDeltaPart({ + id: "2", + delta: "thinking!" + }), + Response.reasoningEndPart({ + id: "2" + }) + ] + const accumulatedParts = yield* Response.accumulateStreamParts([], parts) + const expected = [ + Response.reasoningAccumulatedPart({ + id: "2", + text: "I am thinking!", + status: "done" + }) + ] + assert.deepStrictEqual(accumulatedParts, expected) + }) + ) + + it.effect( + "should return a text part with status 'streaming' for streaming text", + Effect.fnUntraced(function*() { + const parts = [ + Response.textStartPart({ + id: "1" + }), + Response.textDeltaPart({ + id: "1", + delta: "Hello" + }), + Response.textDeltaPart({ + id: "1", + delta: ", " + }), + Response.textDeltaPart({ + id: "1", + delta: "World!" + }) + ] + const accumulatedParts = yield* Response.accumulateStreamParts([], parts) + const expected = [ + Response.textAccumulatedPart({ + id: "1", + text: "Hello, World!", + status: "streaming" + }) + ] + assert.deepStrictEqual(accumulatedParts, expected) + }) + ) + + it.effect( + "should return a reasoning part with status 'streaming' for streaming reasoning", + Effect.fnUntraced(function*() { + const parts = [ + Response.reasoningStartPart({ + id: "2" + }), + Response.reasoningDeltaPart({ + id: "2", + delta: "I " + }), + Response.reasoningDeltaPart({ + id: "2", + delta: "am " + }), + Response.reasoningDeltaPart({ + id: "2", + delta: "thinking!" + }) + ] + const accumulatedParts = yield* Response.accumulateStreamParts([], parts) + const expected = [ + Response.reasoningAccumulatedPart({ + id: "2", + text: "I am thinking!", + status: "streaming" + }) + ] + assert.deepStrictEqual(accumulatedParts, expected) + }) + ) + + it.effect( + "should return a tool with status 'params-done' for complete tool call params", + Effect.fnUntraced(function*() { + const parts = [ + Response.toolParamsStartPart({ + id: "1", + name: "getWeather", + providerExecuted: false + }), + Response.toolParamsDeltaPart({ + id: "1", + delta: "{location: '" + }), + Response.toolParamsDeltaPart({ + id: "1", + delta: "NYC" + }), + Response.toolParamsDeltaPart({ + id: "1", + delta: "'}" + }), + Response.toolParamsEndPart({ + id: "1" + }), + Response.toolCallPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + params: { + location: "NYC" + } + }) + ] as Array> + const accumulatedParts = yield* Response.accumulateStreamParts([], parts) + const expected = [ + Response.toolPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + value: { + status: "params-done", + params: { + location: "NYC" + } + } + }) + ] as Array> + assert.deepStrictEqual(accumulatedParts, expected) + }) + ) + + it.effect( + "should return a tool with status 'params-streaming' for tool call streaming params", + Effect.fnUntraced(function*() { + const parts = [ + Response.toolParamsStartPart({ + id: "1", + name: "getWeather", + providerExecuted: false + }), + Response.toolParamsDeltaPart({ + id: "1", + delta: "{location: '" + }), + Response.toolParamsDeltaPart({ + id: "1", + delta: "NYC" + }) + ] as Array> + const accumulatedParts = yield* Response.accumulateStreamParts([], parts) + const expected = [ + Response.toolPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + value: { + status: "params-streaming", + params: "{location: 'NYC" + } + }) + ] as Array> + assert.deepStrictEqual(accumulatedParts, expected) + }) + ) + + it.effect( + "should accumulate complete tool call params and tool result", + Effect.fnUntraced(function*() { + const parts = [ + Response.toolParamsStartPart({ + id: "1", + name: "getWeather", + providerExecuted: false + }), + Response.toolParamsDeltaPart({ + id: "1", + delta: "{location: '" + }), + Response.toolParamsDeltaPart({ + id: "1", + delta: "NYC" + }), + Response.toolParamsDeltaPart({ + id: "1", + delta: "'}" + }), + Response.toolParamsEndPart({ + id: "1" + }), + Response.toolCallPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + params: { + location: "NYC" + } + }), + Response.toolResultPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + isFailure: false, + result: { + temperature: 12 + }, + encodedResult: { + temperature: 12 + } + }) + ] as Array> + const accumulatedParts = yield* Response.accumulateStreamParts([], parts) + const expected = [ + Response.toolPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + value: { + status: "result-done", + params: { + location: "NYC" + }, + result: { + temperature: 12 + }, + encodedResult: { + temperature: 12 + } + } + }) + ] as Array> + assert.deepStrictEqual(accumulatedParts, expected) + }) + ) + + it.effect( + "should accumulate complete tool call and return tool result error as status 'result-error'", + Effect.fnUntraced(function*() { + const parts = [ + Response.toolParamsStartPart({ + id: "1", + name: "getWeather", + providerExecuted: false + }), + Response.toolParamsDeltaPart({ + id: "1", + delta: "{location: '" + }), + Response.toolParamsDeltaPart({ + id: "1", + delta: "NYC" + }), + Response.toolParamsDeltaPart({ + id: "1", + delta: "'}" + }), + Response.toolParamsEndPart({ + id: "1" + }), + Response.toolCallPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + params: { + location: "NYC" + } + }), + Response.toolResultPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + isFailure: true, + result: { + message: "Unknown location" + }, + encodedResult: { + message: "Unknown location" + } + }) + ] as Array> + const accumulatedParts = yield* Response.accumulateStreamParts([], parts) + const expected = [ + Response.toolPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + value: { + status: "result-error", + params: { + location: "NYC" + }, + result: { + message: "Unknown location" + }, + encodedResult: { + message: "Unknown location" + } + } + }) + ] as Array> + assert.deepStrictEqual(accumulatedParts, expected) + }) + ) + + it.effect( + "should preserve order for the streaming parts", + Effect.fnUntraced(function*() { + const parts = [ + Response.reasoningStartPart({ + id: "2" + }), + Response.reasoningDeltaPart({ + id: "2", + delta: "I " + }), + Response.reasoningDeltaPart({ + id: "2", + delta: "am " + }), + Response.reasoningDeltaPart({ + id: "2", + delta: "thinking!" + }), + Response.reasoningEndPart({ + id: "2" + }), + Response.textStartPart({ + id: "1" + }), + Response.textDeltaPart({ + id: "1", + delta: "Hello" + }), + Response.textDeltaPart({ + id: "1", + delta: ", " + }), + Response.textDeltaPart({ + id: "1", + delta: "World!" + }), + Response.textEndPart({ + id: "1" + }), + Response.toolParamsStartPart({ + id: "1", + name: "getWeather", + providerExecuted: false + }), + Response.toolParamsDeltaPart({ + id: "1", + delta: "{location: '" + }), + Response.toolParamsDeltaPart({ + id: "1", + delta: "NYC" + }), + Response.toolParamsDeltaPart({ + id: "1", + delta: "'}" + }), + Response.toolParamsEndPart({ + id: "1" + }), + Response.toolCallPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + params: { + location: "NYC" + } + }), + Response.toolResultPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + isFailure: true, + result: { + message: "Unknown location" + }, + encodedResult: { + message: "Unknown location" + } + }) + ] as Array> + const accumulatedParts = yield* Response.accumulateStreamParts([], parts) + const expected = [ + Response.reasoningAccumulatedPart({ + id: "2", + text: "I am thinking!", + status: "done" + }), + Response.textAccumulatedPart({ + id: "1", + text: "Hello, World!", + status: "done" + }), + Response.toolPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + value: { + status: "result-error", + params: { + location: "NYC" + }, + result: { + message: "Unknown location" + }, + encodedResult: { + message: "Unknown location" + } + } + }) + ] + assert.deepStrictEqual(accumulatedParts, expected) + }) + ) + + it.effect( + "should return an empty accumulated parts if no stream parts provided", + Effect.fnUntraced(function*() { + const parts: Array> = [] + const accumulatedParts = yield* Response.accumulateStreamParts([], parts) + const expected: Array> = [] + assert.deepStrictEqual(accumulatedParts, expected) + }) + ) + }) + + describe("accumulateParts", () => { + const IdGeneratorTest = Layer.effect( + IdGenerator.IdGenerator, + Effect.gen(function*() { + let counter = 1 + return { + generateId: () => + Effect.gen(function*() { + const id = counter.toString() + counter++ + return id + }) + } + }) + ) + + it.effect( + "should return a text accumulated part for a text part", + () => + Effect.gen(function*() { + const parts: Array> = [ + Response.textPart({ + text: "Hello, World!" + }) + ] + const accumulatedParts = yield* Response.accumulateParts([], parts) + const expected: Array> = [ + Response.textAccumulatedPart({ + id: "1", + text: "Hello, World!", + status: "done" + }) + ] + assert.deepStrictEqual(accumulatedParts, expected) + }).pipe( + Effect.provide(IdGeneratorTest) + ) + ) + + it.effect( + "should return a reasoning accumulated part for a reasoning part", + () => + Effect.gen(function*() { + const parts: Array> = [ + Response.reasoningPart({ + text: "I am thinking!" + }) + ] + const accumulatedParts = yield* Response.accumulateParts([], parts) + const expected: Array> = [ + Response.reasoningAccumulatedPart({ + id: "1", + text: "I am thinking!", + status: "done" + }) + ] + assert.deepStrictEqual(accumulatedParts, expected) + }).pipe( + Effect.provide(IdGeneratorTest) + ) + ) + + it.effect( + "should return a tool accumulated part with status 'params-done' for a tool call part", + () => + Effect.gen(function*() { + const parts: Array> = [ + Response.toolCallPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + params: { + location: "NYC" + } + }) + ] + const accumulatedParts = yield* Response.accumulateParts([], parts) + const expected: Array> = [ + Response.toolPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + value: { + status: "params-done", + params: { + location: "NYC" + } + } + }) + ] + assert.deepStrictEqual(accumulatedParts, expected) + }).pipe( + Effect.provide(IdGeneratorTest) + ) + ) + + it.effect( + "should return a tool accumulated part with status 'result-done' for a tool call part and a successful tool result part respectively", + () => + Effect.gen(function*() { + const parts: Array> = [ + Response.toolCallPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + params: { + location: "NYC" + } + }), + Response.toolResultPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + isFailure: false, + result: { + teperature: 12 + }, + encodedResult: { + teperature: 12 + } + }) + ] + const accumulatedParts = yield* Response.accumulateParts([], parts) + const expected: Array> = [ + Response.toolPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + value: { + status: "result-done", + params: { + location: "NYC" + }, + result: { + teperature: 12 + }, + encodedResult: { + teperature: 12 + } + } + }) + ] + assert.deepStrictEqual(accumulatedParts, expected) + }).pipe( + Effect.provide(IdGeneratorTest) + ) + ) + + it.effect( + "should return a tool accumulated part with status 'result-error' for a tool call part and a failure tool result part respectively", + () => + Effect.gen(function*() { + const parts: Array> = [ + Response.toolCallPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + params: { + location: "NYC" + } + }), + Response.toolResultPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + isFailure: true, + result: { + message: "Uknown location" + }, + encodedResult: { + message: "Uknown location" + } + }) + ] + const accumulatedParts = yield* Response.accumulateParts([], parts) + const expected: Array> = [ + Response.toolPart({ + id: "1", + name: "getWeather", + providerExecuted: false, + value: { + status: "result-error", + params: { + location: "NYC" + }, + result: { + message: "Uknown location" + }, + encodedResult: { + message: "Uknown location" + } + } + }) + ] + assert.deepStrictEqual(accumulatedParts, expected) + }).pipe( + Effect.provide(IdGeneratorTest) + ) + ) + + it.effect( + "should presever the order of the parts", + () => + Effect.gen(function*() { + const parts: Array> = [ + Response.reasoningPart({ + text: "I am thinking!" + }), + Response.textPart({ + text: "Hello, World!" + }), + Response.toolCallPart({ + id: "3", + name: "getWeather", + providerExecuted: false, + params: { + location: "NYC" + } + }), + Response.toolResultPart({ + id: "3", + name: "getWeather", + providerExecuted: false, + isFailure: true, + result: { + message: "Uknown location" + }, + encodedResult: { + message: "Uknown location" + } + }) + ] + const accumulatedParts = yield* Response.accumulateParts([], parts) + const expected: Array> = [ + Response.reasoningAccumulatedPart({ + id: "1", + status: "done", + text: "I am thinking!" + }), + Response.textAccumulatedPart({ + id: "2", + status: "done", + text: "Hello, World!" + }), + Response.toolPart({ + id: "3", + name: "getWeather", + providerExecuted: false, + value: { + status: "result-error", + params: { + location: "NYC" + }, + result: { + message: "Uknown location" + }, + encodedResult: { + message: "Uknown location" + } + } + }) + ] + assert.deepStrictEqual(accumulatedParts, expected) + }).pipe( + Effect.provide(IdGeneratorTest) + ) + ) + }) +})