diff --git a/.changeset/durable-agent-v6-compat.md b/.changeset/durable-agent-v6-compat.md new file mode 100644 index 000000000..32df6c261 --- /dev/null +++ b/.changeset/durable-agent-v6-compat.md @@ -0,0 +1,5 @@ +--- +'@workflow/ai': patch +--- + +Add AI SDK v6 compatibility to DurableAgent: accept model objects (V2/V3) with auto-conversion to string IDs, normalize V3 finish reason and usage formats, pass through typed ToolResultOutput without re-wrapping, and add `instructions` alias for `system` diff --git a/docs/content/docs/api-reference/workflow-ai/durable-agent.mdx b/docs/content/docs/api-reference/workflow-ai/durable-agent.mdx index 392786081..31b65b9e2 100644 --- a/docs/content/docs/api-reference/workflow-ai/durable-agent.mdx +++ b/docs/content/docs/api-reference/workflow-ai/durable-agent.mdx @@ -199,8 +199,72 @@ export default OutputSpecification;`} - **Flexible Tool Implementation**: Tools can be implemented as workflow steps for automatic retries, or as regular workflow-level logic - **Stream Processing**: Handles streaming responses and tool calls in a structured way - **Workflow Native**: Fully integrated with Workflow DevKit for production-grade reliability +- **AI SDK v5 and v6**: Accepts `LanguageModelV2` and `LanguageModelV3` model objects, with runtime normalization of V3 stream differences - **AI SDK Parity**: Supports the same options as AI SDK's `streamText` including generation settings, callbacks, and structured output +## Model Configuration + +The `model` field accepts three forms: + +| Form | Example | Recommended | +|------|---------|-------------| +| String ID | `"anthropic/claude-sonnet-4.5"` | Yes | +| Model object | `anthropic("claude-sonnet-4.5")` | Accepted | +| Factory function | `() => Promise` | Deprecated | + +**String IDs** are the recommended approach. The string is resolved via the AI SDK gateway inside the workflow step, so it crosses the step boundary cleanly. + +**Model objects** from both AI SDK v5 (`LanguageModelV2`) and AI SDK v6 (`LanguageModelV3`) are accepted. They are automatically converted to string IDs using the object's `provider` and `modelId` fields. Middleware and wrappers applied to the model object are **not preserved** across the step boundary — use `providerOptions` to configure provider-specific settings instead. + +**Factory functions** are deprecated and will not work in workflow mode. Use a string model ID instead. + + +Because workflow steps serialize all arguments across the step boundary, model objects (which contain methods) cannot be passed directly to the underlying `streamText` call. DurableAgent extracts the `provider/modelId` string and resolves it via the gateway inside the step. + + +### Instructions Alias + +The `instructions` field is an alias for `system`, available on both the constructor and `stream()` options. When both are provided, `instructions` takes precedence: + +```typescript +const agent = new DurableAgent({ + model: "anthropic/claude-sonnet-4.5", + instructions: "You are a helpful coding assistant.", +}); + +// Override per-stream call +await agent.stream({ + messages, + writable, + instructions: "You are a helpful writing assistant.", +}); +``` + +### Tool Result Types + +Tool `execute` functions can return typed `ToolResultOutput` values that pass through to the model without re-wrapping. Supported output types include `text`, `json`, `content` (for multimodal responses), `error-text`, and `error-json`: + +```typescript +tools: { + analyzeImage: { + description: "Analyze an image", + inputSchema: z.object({ url: z.string() }), + execute: async ({ url }) => { + // Return typed content — passes through to the model as-is + return { + type: "content" as const, + value: [ + { type: "text", text: "Analysis complete" }, + { type: "image", data: imageBuffer, mimeType: "image/png" }, + ], + }; + }, + }, +}, +``` + +If a tool returns a plain string or object without a recognized `type` field, DurableAgent wraps it as `text` or `json` automatically (the existing behavior). + ## Good to Know - Tools can be implemented as workflow steps (using `"use step"` for automatic retries), or as regular workflow-level logic @@ -213,6 +277,7 @@ export default OutputSpecification;`} - Generation settings (temperature, maxOutputTokens, etc.) can be set on the constructor and overridden per-stream call - Use `activeTools` to limit which tools are available for a specific stream call - The `onFinish` callback is called when all steps complete; `onAbort` is called if aborted +- The `instructions` field is an alias for `system` — use whichever name you prefer ## Examples @@ -834,6 +899,64 @@ async function saveConversation(messages: UIMessage[]) { The `uiMessages` property is only available when `collectUIMessages` is set to `true`. When disabled, `uiMessages` is `undefined`. +## Migrating from Wrapped Models + +If you previously used `wrapLanguageModel` with middleware (e.g., `defaultSettingsMiddleware` for gateway routing or thinking configuration), those settings are lost at the step boundary. Replace them with `providerOptions`, which are serialized as plain objects and cross the boundary correctly. + +### Before + +```typescript +import { wrapLanguageModel, defaultSettingsMiddleware } from "ai"; + +// Middleware carries settings — these are lost at the step boundary +const model = wrapLanguageModel({ + model: gateway("anthropic/claude-sonnet-4.5"), + middleware: defaultSettingsMiddleware({ + settings: { + providerOptions: { + gateway: { only: ["anthropic"], order: ["anthropic"] }, + anthropic: { thinking: { type: "enabled", budgetTokens: 10000 } }, + }, + }, + }), +}); + +const agent = new DurableAgent({ model }); +``` + +### After + +```typescript +// Settings passed directly — these cross the step boundary as plain objects +const agent = new DurableAgent({ + model: "anthropic/claude-sonnet-4.5", + providerOptions: { + gateway: { only: ["anthropic"], order: ["anthropic"] }, + anthropic: { thinking: { type: "enabled", budgetTokens: 10000 } }, + }, +}); +``` + +### Dynamic Per-Step Configuration + +Use `prepareStep` to adjust `providerOptions` dynamically based on step context: + +```typescript +const agent = new DurableAgent({ + model: "anthropic/claude-sonnet-4.5", +}); + +await agent.stream({ + messages, + writable, + prepareStep: ({ stepNumber }) => ({ + providerOptions: stepNumber > 3 + ? { anthropic: { thinking: { type: "enabled", budgetTokens: 20000 } } } + : undefined, + }), +}); +``` + ## See Also - [Building Durable AI Agents](/docs/ai) - Complete guide to creating durable agents diff --git a/packages/ai/src/agent/do-stream-step.test.ts b/packages/ai/src/agent/do-stream-step.test.ts deleted file mode 100644 index b9220a288..000000000 --- a/packages/ai/src/agent/do-stream-step.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { normalizeFinishReason } from './do-stream-step.js'; - -describe('normalizeFinishReason', () => { - describe('string finish reasons', () => { - it('should pass through "stop"', () => { - expect(normalizeFinishReason('stop')).toBe('stop'); - }); - - it('should pass through "tool-calls"', () => { - expect(normalizeFinishReason('tool-calls')).toBe('tool-calls'); - }); - - it('should pass through "length"', () => { - expect(normalizeFinishReason('length')).toBe('length'); - }); - - it('should pass through "content-filter"', () => { - expect(normalizeFinishReason('content-filter')).toBe('content-filter'); - }); - - it('should pass through "error"', () => { - expect(normalizeFinishReason('error')).toBe('error'); - }); - - it('should pass through "other"', () => { - expect(normalizeFinishReason('other')).toBe('other'); - }); - - it('should pass through "unknown"', () => { - expect(normalizeFinishReason('unknown')).toBe('unknown'); - }); - }); - - describe('object finish reasons', () => { - it('should extract "stop" from object', () => { - expect(normalizeFinishReason({ type: 'stop' })).toBe('stop'); - }); - - it('should extract "tool-calls" from object', () => { - expect(normalizeFinishReason({ type: 'tool-calls' })).toBe('tool-calls'); - }); - - it('should extract "length" from object', () => { - expect(normalizeFinishReason({ type: 'length' })).toBe('length'); - }); - - it('should extract "content-filter" from object', () => { - expect(normalizeFinishReason({ type: 'content-filter' })).toBe( - 'content-filter' - ); - }); - - it('should extract "error" from object', () => { - expect(normalizeFinishReason({ type: 'error' })).toBe('error'); - }); - - it('should extract "other" from object', () => { - expect(normalizeFinishReason({ type: 'other' })).toBe('other'); - }); - - it('should extract "unknown" from object', () => { - expect(normalizeFinishReason({ type: 'unknown' })).toBe('unknown'); - }); - - it('should return "unknown" for object without type property', () => { - expect(normalizeFinishReason({})).toBe('unknown'); - }); - - it('should return "unknown" for object with null type', () => { - expect(normalizeFinishReason({ type: null })).toBe('unknown'); - }); - - it('should return "unknown" for object with undefined type', () => { - expect(normalizeFinishReason({ type: undefined })).toBe('unknown'); - }); - - it('should handle object with additional properties', () => { - expect( - normalizeFinishReason({ - type: 'stop', - reason: 'end_turn', - metadata: { foo: 'bar' }, - }) - ).toBe('stop'); - }); - }); - - describe('edge cases', () => { - it('should return "unknown" for undefined', () => { - expect(normalizeFinishReason(undefined)).toBe('unknown'); - }); - - it('should return "unknown" for null', () => { - expect(normalizeFinishReason(null)).toBe('unknown'); - }); - - it('should return "unknown" for number', () => { - expect(normalizeFinishReason(42)).toBe('unknown'); - }); - - it('should return "unknown" for boolean', () => { - expect(normalizeFinishReason(true)).toBe('unknown'); - }); - - it('should return "unknown" for array', () => { - expect(normalizeFinishReason(['stop'])).toBe('unknown'); - }); - - it('should handle empty string', () => { - expect(normalizeFinishReason('')).toBe(''); - }); - }); - - describe('bug reproduction', () => { - it('should handle object format that caused [object Object] error', () => { - const normalized = normalizeFinishReason({ type: 'stop' }); - expect(normalized).toBe('stop'); - expect(typeof normalized).toBe('string'); - }); - - it('should handle tool-calls object format', () => { - const normalized = normalizeFinishReason({ type: 'tool-calls' }); - expect(normalized).toBe('tool-calls'); - expect(typeof normalized).toBe('string'); - }); - }); -}); diff --git a/packages/ai/src/agent/do-stream-step.ts b/packages/ai/src/agent/do-stream-step.ts index b0d89212f..991c49519 100644 --- a/packages/ai/src/agent/do-stream-step.ts +++ b/packages/ai/src/agent/do-stream-step.ts @@ -7,7 +7,6 @@ import type { SharedV2ProviderOptions, } from '@ai-sdk/provider'; import { - type FinishReason, gateway, generateId, type StepResult, @@ -21,7 +20,30 @@ import type { StreamTextTransform, TelemetrySettings, } from './durable-agent.js'; -import type { CompatibleLanguageModel } from './types.js'; +import { normalizeFinishReason, normalizeUsage } from './normalize.js'; +import type { + CompatibleLanguageModel, + V3DoStreamRequestMetadata, + V3ToolCallExtension, + V3ToolInputStartExtension, + V3ToolResultExtension, +} from './types.js'; + +/** + * Internal model interface used at runtime inside doStreamStep. + * + * Both LanguageModelV2 (AI SDK v5) and LanguageModelV3 (AI SDK v6) satisfy + * this at runtime. The V2 call options and stream part types are used here + * because they match the installed @ai-sdk/provider version; when a V3 model + * is resolved via gateway(), the structural differences (finish reason as + * object, nested usage, tool-approval stream parts) are bridged by + * {@link normalizeFinishReason} and {@link normalizeUsage} in normalize.ts. + */ +interface StreamableModel { + doStream(options: LanguageModelV2CallOptions): PromiseLike<{ + stream: ReadableStream; + }>; +} export type FinishPart = Extract; @@ -111,16 +133,15 @@ export async function doStreamStep( ) { 'use step'; - // Model can be LanguageModelV2 (AI SDK v5) or LanguageModelV3 (AI SDK v6) - // Both have compatible doStream interfaces for our use case - let model: CompatibleLanguageModel | undefined; + // Resolve the model to a StreamableModel for the doStream call. + // gateway() returns LanguageModelV2 in AI SDK v5 and LanguageModelV3 in v6. + // Both satisfy StreamableModel at runtime; V3 differences (finish reason + // as {unified,raw}, nested usage) are normalized in chunksToStep. + let model: StreamableModel; if (typeof modelInit === 'string') { - // gateway() returns LanguageModelV2 in AI SDK v5 and LanguageModelV3 in AI SDK v6 - // Both are compatible at runtime for doStream operations - model = gateway(modelInit) as CompatibleLanguageModel; + model = gateway(modelInit) as StreamableModel; } else if (typeof modelInit === 'function') { - // User-provided model factory - could return V2 or V3 - model = await modelInit(); + model = (await modelInit()) as StreamableModel; } else { throw new Error( 'Invalid "model initialization" argument. Must be a string or a function that returns a LanguageModel instance.' @@ -169,6 +190,10 @@ export async function doStreamStep( const result = await model.doStream(callOptions); + // Capture request metadata from V3 doStream result for telemetry + const doStreamRequest = (result as { request?: V3DoStreamRequestMetadata }) + .request; + let finish: FinishPart | undefined; const toolCalls: LanguageModelV2ToolCall[] = []; // Map of tool call ID to provider-executed tool result @@ -215,12 +240,24 @@ export async function doStreamStep( } else if (chunk.type === 'tool-result') { // Capture provider-executed tool results if (chunk.providerExecuted) { - providerExecutedToolResults.set(chunk.toolCallId, { - toolCallId: chunk.toolCallId, - toolName: chunk.toolName, - result: chunk.result, - isError: chunk.isError, - }); + const preliminary = (chunk as V3ToolResultExtension).preliminary; + // Only store non-preliminary results, or overwrite preliminary with final + if (!preliminary) { + providerExecutedToolResults.set(chunk.toolCallId, { + toolCallId: chunk.toolCallId, + toolName: chunk.toolName, + result: chunk.result, + isError: chunk.isError, + }); + } else if (!providerExecutedToolResults.has(chunk.toolCallId)) { + // Store preliminary result as placeholder until final arrives + providerExecutedToolResults.set(chunk.toolCallId, { + toolCallId: chunk.toolCallId, + toolName: chunk.toolName, + result: chunk.result, + isError: chunk.isError, + }); + } } } else if (chunk.type === 'finish') { finish = chunk; @@ -381,6 +418,8 @@ export async function doStreamStep( } case 'tool-input-start': { + // V3 adds optional title and dynamic fields + const v3Part = part as typeof part & V3ToolInputStartExtension; controller.enqueue({ type: 'tool-input-start', toolCallId: part.id, @@ -388,6 +427,8 @@ export async function doStreamStep( ...(part.providerExecuted != null ? { providerExecuted: part.providerExecuted } : {}), + ...(v3Part.title != null ? { title: v3Part.title } : {}), + ...(v3Part.dynamic != null ? { dynamic: v3Part.dynamic } : {}), }); break; } @@ -471,9 +512,14 @@ export async function doStreamStep( } default: { - // Handle any other chunk types gracefully - // const exhaustiveCheck: never = partType; - // console.warn(`Unknown chunk type: ${partType}`); + // V3 tool-approval-request: not yet supported by DurableAgent. + // Warn rather than silently hang waiting for an approval response. + if (partType === 'tool-approval-request') { + console.warn( + `[DurableAgent] Received tool-approval-request but tool approval is not yet supported. ` + + `The tool call may hang. Consider using tools without needsApproval or handling approval externally.` + ); + } } } }, @@ -492,7 +538,13 @@ export async function doStreamStep( ) .pipeTo(writable, { preventClose: true }); - const step = chunksToStep(chunks, toolCalls, conversationPrompt, finish); + const step = chunksToStep( + chunks, + toolCalls, + conversationPrompt, + finish, + doStreamRequest + ); return { toolCalls, finish, @@ -502,34 +554,15 @@ export async function doStreamStep( }; } -/** - * Normalize the finish reason to the AI SDK FinishReason type. - * AI SDK v6 may return an object with a 'type' property, - * while AI SDK v5 returns a plain string. This function handles both. - * - * @internal Exported for testing - */ -export function normalizeFinishReason(rawFinishReason: unknown): FinishReason { - // Handle object-style finish reason (possible in some AI SDK versions/providers) - if (typeof rawFinishReason === 'object' && rawFinishReason !== null) { - const objReason = rawFinishReason as { type?: string }; - return (objReason.type as FinishReason) ?? 'unknown'; - } - // Handle string finish reason (standard format) - if (typeof rawFinishReason === 'string') { - return rawFinishReason as FinishReason; - } - return 'unknown'; -} - // This is a stand-in for logic in the AI-SDK streamText code which aggregates // chunks into a single step result. function chunksToStep( chunks: LanguageModelV2StreamPart[], toolCalls: LanguageModelV2ToolCall[], conversationPrompt: LanguageModelV2Prompt, - finish?: FinishPart -): StepResult { + finish?: FinishPart, + doStreamRequest?: { body?: unknown } +): StepResult { // Transform chunks to a single step result const text = chunks .filter( @@ -600,16 +633,46 @@ function chunksToStep( ) .map((chunk) => chunk); - const stepResult: StepResult = { + // Normalize finish reason — returns both unified and raw + const { finishReason, rawFinishReason } = normalizeFinishReason( + finish?.finishReason + ); + + // Map tool calls using actual dynamic flag from stream (not hardcoded) + const mapToolCall = (toolCall: LanguageModelV2ToolCall) => { + const isDynamic = (toolCall as V3ToolCallExtension).dynamic === true; + return { + type: 'tool-call' as const, + toolCallId: toolCall.toolCallId, + toolName: toolCall.toolName, + input: JSON.parse(toolCall.input), + ...(isDynamic ? { dynamic: true as const } : {}), + }; + }; + + const mappedToolCalls = toolCalls.map(mapToolCall); + const staticCalls = mappedToolCalls.filter((tc) => !('dynamic' in tc)); + const dynamicCalls = mappedToolCalls.filter((tc) => 'dynamic' in tc); + + // Use doStreamRequest body if available (V3), otherwise synthesize + const requestBody = doStreamRequest?.body + ? doStreamRequest.body + : JSON.stringify({ + prompt: conversationPrompt, + tools: mappedToolCalls, + }); + + // Build step result — includes v6 fields (rawFinishReason, expanded usage) + // that v5 consumers will simply ignore. + // + // The `as StepResult` cast is required because we construct the + // object manually from stream parts and the exact shape may drift between + // AI SDK versions. This mirrors the AI SDK's own internal pattern of + // building StepResult objects imperatively. + const stepResult: StepResult = { content: [ ...(text ? [{ type: 'text' as const, text }] : []), - ...toolCalls.map((toolCall) => ({ - type: 'tool-call' as const, - toolCallId: toolCall.toolCallId, - toolName: toolCall.toolName, - input: JSON.parse(toolCall.input), - dynamic: true as const, - })), + ...mappedToolCalls, ], text, reasoning: reasoning.map((chunk) => ({ @@ -619,38 +682,19 @@ function chunksToStep( reasoningText: reasoningText || undefined, files, sources, - toolCalls: toolCalls.map((toolCall) => ({ - type: 'tool-call' as const, - toolCallId: toolCall.toolCallId, - toolName: toolCall.toolName, - input: JSON.parse(toolCall.input), - dynamic: true as const, - })), - staticToolCalls: [], - dynamicToolCalls: toolCalls.map((toolCall) => ({ - type: 'tool-call' as const, - toolCallId: toolCall.toolCallId, - toolName: toolCall.toolName, - input: JSON.parse(toolCall.input), - dynamic: true as const, - })), + toolCalls: mappedToolCalls, + staticToolCalls: staticCalls, + dynamicToolCalls: dynamicCalls, toolResults: [], staticToolResults: [], dynamicToolResults: [], - finishReason: normalizeFinishReason(finish?.finishReason), - usage: finish?.usage || { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + finishReason, + // rawFinishReason is v6-only; v5 StepResult type ignores extra properties + ...(rawFinishReason !== undefined ? { rawFinishReason } : {}), + usage: normalizeUsage(finish?.usage), warnings: streamStart?.warnings, request: { - body: JSON.stringify({ - prompt: conversationPrompt, - tools: toolCalls.map((toolCall) => ({ - type: 'tool-call' as const, - toolCallId: toolCall.toolCallId, - toolName: toolCall.toolName, - input: JSON.parse(toolCall.input), - dynamic: true as const, - })), - }), + body: requestBody, }, response: { id: responseMetadata?.id ?? 'unknown', @@ -658,8 +702,8 @@ function chunksToStep( modelId: responseMetadata?.modelId ?? 'unknown', messages: [], }, - providerMetadata: finish?.providerMetadata || {}, - }; + providerMetadata: finish?.providerMetadata ?? {}, + } as StepResult; return stepResult; } diff --git a/packages/ai/src/agent/durable-agent.test.ts b/packages/ai/src/agent/durable-agent.test.ts index 383207c22..2b038222a 100644 --- a/packages/ai/src/agent/durable-agent.test.ts +++ b/packages/ai/src/agent/durable-agent.test.ts @@ -30,8 +30,11 @@ import type { } from './durable-agent.js'; import type { StreamTextIteratorYieldValue } from './stream-text-iterator.js'; +/** Default string model ID for tests (streamTextIterator is mocked, so the value is opaque). */ +const TEST_MODEL = 'test/test-model'; + /** - * Creates a mock LanguageModelV2 for testing + * Creates a mock LanguageModelV2 for tests that specifically need model objects. */ function createMockModel(): LanguageModelV2 { return { @@ -69,10 +72,8 @@ describe('DurableAgent', () => { // We need to test the executeTool function indirectly through the agent // Create a mock model that will trigger tool calls - const mockModel = createMockModel(); - const agent = new DurableAgent({ - model: async () => mockModel, + model: TEST_MODEL, tools, }); @@ -145,10 +146,8 @@ describe('DurableAgent', () => { }, }; - const mockModel = createMockModel(); - const agent = new DurableAgent({ - model: async () => mockModel, + model: TEST_MODEL, tools, }); @@ -199,10 +198,8 @@ describe('DurableAgent', () => { }, }; - const mockModel = createMockModel(); - const agent = new DurableAgent({ - model: async () => mockModel, + model: TEST_MODEL, tools, }); @@ -271,10 +268,8 @@ describe('DurableAgent', () => { }, }; - const mockModel = createMockModel(); - const agent = new DurableAgent({ - model: async () => mockModel, + model: TEST_MODEL, tools, }); @@ -357,10 +352,8 @@ describe('DurableAgent', () => { }, }; - const mockModel = createMockModel(); - const agent = new DurableAgent({ - model: async () => mockModel, + model: TEST_MODEL, tools, }); @@ -453,10 +446,8 @@ describe('DurableAgent', () => { }); it('should handle provider-executed tool errors with isError flag', async () => { - const mockModel = createMockModel(); - const agent = new DurableAgent({ - model: async () => mockModel, + model: TEST_MODEL, tools: {}, }); @@ -530,10 +521,8 @@ describe('DurableAgent', () => { .spyOn(console, 'warn') .mockImplementation(() => {}); - const mockModel = createMockModel(); - const agent = new DurableAgent({ - model: async () => mockModel, + model: TEST_MODEL, tools: {}, }); @@ -607,10 +596,8 @@ describe('DurableAgent', () => { describe('prepareStep callback', () => { it('should pass prepareStep callback to streamTextIterator', async () => { - const mockModel = createMockModel(); - const agent = new DurableAgent({ - model: async () => mockModel, + model: TEST_MODEL, tools: {}, }); @@ -644,10 +631,8 @@ describe('DurableAgent', () => { }); it('should allow prepareStep to modify messages', async () => { - const mockModel = createMockModel(); - const agent = new DurableAgent({ - model: async () => mockModel, + model: TEST_MODEL, tools: {}, }); @@ -690,10 +675,8 @@ describe('DurableAgent', () => { }); it('should allow prepareStep to change model dynamically', async () => { - const mockModel = createMockModel(); - const agent = new DurableAgent({ - model: async () => mockModel, + model: TEST_MODEL, tools: {}, }); @@ -735,10 +718,8 @@ describe('DurableAgent', () => { }); it('should provide step information to prepareStep callback', async () => { - const mockModel = createMockModel(); - const agent = new DurableAgent({ - model: async () => mockModel, + model: TEST_MODEL, tools: {}, }); @@ -805,10 +786,8 @@ describe('DurableAgent', () => { }, }; - const mockModel = createMockModel(); - const agent = new DurableAgent({ - model: async () => mockModel, + model: TEST_MODEL, tools, }); @@ -894,10 +873,8 @@ describe('DurableAgent', () => { }, }; - const mockModel = createMockModel(); - const agent = new DurableAgent({ - model: async () => mockModel, + model: TEST_MODEL, tools, }); @@ -983,10 +960,8 @@ describe('DurableAgent', () => { }, }; - const mockModel = createMockModel(); - const agent = new DurableAgent({ - model: async () => mockModel, + model: TEST_MODEL, tools, }); @@ -1094,10 +1069,8 @@ describe('DurableAgent', () => { describe('generation settings', () => { it('should pass generation settings from constructor to streamTextIterator', async () => { - const mockModel = createMockModel(); - const agent = new DurableAgent({ - model: async () => mockModel, + model: TEST_MODEL, tools: {}, temperature: 0.7, maxOutputTokens: 1000, @@ -1136,10 +1109,8 @@ describe('DurableAgent', () => { }); it('should allow stream options to override constructor generation settings', async () => { - const mockModel = createMockModel(); - const agent = new DurableAgent({ - model: async () => mockModel, + model: TEST_MODEL, tools: {}, temperature: 0.7, }); @@ -1177,10 +1148,8 @@ describe('DurableAgent', () => { describe('maxSteps', () => { it('should pass maxSteps to streamTextIterator', async () => { - const mockModel = createMockModel(); - const agent = new DurableAgent({ - model: async () => mockModel, + model: TEST_MODEL, tools: {}, }); @@ -1213,10 +1182,8 @@ describe('DurableAgent', () => { describe('toolChoice', () => { it('should pass toolChoice from constructor to streamTextIterator', async () => { - const mockModel = createMockModel(); - const agent = new DurableAgent({ - model: async () => mockModel, + model: TEST_MODEL, tools: {}, toolChoice: 'required', }); @@ -1247,10 +1214,8 @@ describe('DurableAgent', () => { }); it('should allow stream options to override constructor toolChoice', async () => { - const mockModel = createMockModel(); - const agent = new DurableAgent({ - model: async () => mockModel, + model: TEST_MODEL, tools: {}, toolChoice: 'auto', }); @@ -1302,10 +1267,8 @@ describe('DurableAgent', () => { }, }; - const mockModel = createMockModel(); - const agent = new DurableAgent({ - model: async () => mockModel, + model: TEST_MODEL, tools, }); @@ -1340,10 +1303,8 @@ describe('DurableAgent', () => { describe('callbacks', () => { it('should pass onError callback to streamTextIterator', async () => { - const mockModel = createMockModel(); - const agent = new DurableAgent({ - model: async () => mockModel, + model: TEST_MODEL, tools: {}, }); @@ -1387,10 +1348,8 @@ describe('DurableAgent', () => { }, }; - const mockModel = createMockModel(); - const agent = new DurableAgent({ - model: async () => mockModel, + model: TEST_MODEL, tools, }); @@ -1440,10 +1399,8 @@ describe('DurableAgent', () => { }); it('should call onFinish with steps and messages when streaming completes', async () => { - const mockModel = createMockModel(); - const agent = new DurableAgent({ - model: async () => mockModel, + model: TEST_MODEL, tools: {}, }); @@ -1453,7 +1410,7 @@ describe('DurableAgent', () => { }); const { streamTextIterator } = await import('./stream-text-iterator.js'); - const mockStep: StepResult = { + const mockStep: StepResult = { content: [{ type: 'text', text: 'Hello' }], text: 'Hello', reasoningText: undefined, @@ -1472,7 +1429,7 @@ describe('DurableAgent', () => { }, warnings: [], // We're missing some properties that aren't relevant for the test - } as unknown as StepResult; + } as unknown as StepResult; const mockMessages: LanguageModelV2Prompt = [ { role: 'user', content: [{ type: 'text', text: 'test' }] }, ]; @@ -1511,10 +1468,8 @@ describe('DurableAgent', () => { }); it('should call onAbort when abort signal is already aborted', async () => { - const mockModel = createMockModel(); - const agent = new DurableAgent({ - model: async () => mockModel, + model: TEST_MODEL, tools: {}, }); @@ -1561,10 +1516,8 @@ describe('DurableAgent', () => { }, }; - const mockModel = createMockModel(); - const agent = new DurableAgent({ - model: async () => mockModel, + model: TEST_MODEL, tools, }); @@ -1613,10 +1566,8 @@ describe('DurableAgent', () => { describe('stream result', () => { it('should return messages and steps in result', async () => { - const mockModel = createMockModel(); - const agent = new DurableAgent({ - model: async () => mockModel, + model: TEST_MODEL, tools: {}, }); @@ -1625,7 +1576,7 @@ describe('DurableAgent', () => { close: vi.fn(), }); - const mockStep: StepResult = { + const mockStep: StepResult = { content: [{ type: 'text', text: 'Hello' }], text: 'Hello', reasoningText: undefined, @@ -1644,7 +1595,7 @@ describe('DurableAgent', () => { }, warnings: [], // We're missing some properties that aren't relevant for the test - } as unknown as StepResult; + } as unknown as StepResult; const finalMessages: LanguageModelV2Prompt = [ { role: 'user', content: [{ type: 'text', text: 'test' }] }, { role: 'assistant', content: [{ type: 'text', text: 'Hello' }] }, @@ -1697,10 +1648,8 @@ describe('DurableAgent', () => { }, }; - const mockModel = createMockModel(); - const agent = new DurableAgent({ - model: async () => mockModel, + model: TEST_MODEL, tools, }); @@ -1759,10 +1708,8 @@ describe('DurableAgent', () => { describe('includeRawChunks', () => { it('should pass includeRawChunks to streamTextIterator', async () => { - const mockModel = createMockModel(); - const agent = new DurableAgent({ - model: async () => mockModel, + model: TEST_MODEL, tools: {}, }); @@ -1795,8 +1742,6 @@ describe('DurableAgent', () => { describe('experimental_telemetry', () => { it('should pass telemetry settings from constructor to streamTextIterator', async () => { - const mockModel = createMockModel(); - const telemetrySettings = { isEnabled: true, functionId: 'test-agent', @@ -1804,7 +1749,7 @@ describe('DurableAgent', () => { }; const agent = new DurableAgent({ - model: async () => mockModel, + model: TEST_MODEL, tools: {}, experimental_telemetry: telemetrySettings, }); @@ -1835,10 +1780,8 @@ describe('DurableAgent', () => { }); it('should allow stream options to override constructor telemetry', async () => { - const mockModel = createMockModel(); - const agent = new DurableAgent({ - model: async () => mockModel, + model: TEST_MODEL, tools: {}, experimental_telemetry: { functionId: 'constructor-id' }, }); @@ -1872,12 +1815,23 @@ describe('DurableAgent', () => { }); }); - describe('collectUIMessages', () => { - it('should return undefined uiMessages when collectUIMessages is false', async () => { - const mockModel = createMockModel(); + describe('model object acceptance', () => { + it('should convert V2 model object to string and warn', async () => { + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}); + + const mockModel: LanguageModelV2 = { + specificationVersion: 'v2' as const, + provider: 'test', + modelId: 'test-model', + doGenerate: vi.fn(), + doStream: vi.fn(), + supportedUrls: {}, + }; const agent = new DurableAgent({ - model: async () => mockModel, + model: mockModel, tools: {}, }); @@ -1894,20 +1848,45 @@ describe('DurableAgent', () => { mockIterator as unknown as MockIterator ); - const result = await agent.stream({ + await agent.stream({ messages: [{ role: 'user', content: 'test' }], writable: mockWritable, - collectUIMessages: false, }); - expect(result.uiMessages).toBeUndefined(); + // Verify model was converted to string + expect(streamTextIterator).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'test/test-model', + }) + ); + + // Verify informational warning was emitted + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Model object "test/test-model" was converted to a string' + ) + ); + + consoleWarnSpy.mockRestore(); }); + }); - it('should return undefined uiMessages when collectUIMessages is not set', async () => { - const mockModel = createMockModel(); + describe('V3 model object', () => { + it('should convert V3 model object to string', async () => { + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}); + + // V3 models only need identity properties — doStream is not required + // because resolveModelId converts model objects to strings + const v3Model = { + specificationVersion: 'v3' as const, + provider: 'anthropic', + modelId: 'claude-opus', + }; const agent = new DurableAgent({ - model: async () => mockModel, + model: v3Model, tools: {}, }); @@ -1924,19 +1903,37 @@ describe('DurableAgent', () => { mockIterator as unknown as MockIterator ); - const result = await agent.stream({ + await agent.stream({ messages: [{ role: 'user', content: 'test' }], writable: mockWritable, }); - expect(result.uiMessages).toBeUndefined(); + // Verify model was converted to string + expect(streamTextIterator).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'anthropic/claude-opus', + }) + ); + + consoleWarnSpy.mockRestore(); }); - it('should pass collectUIChunks to streamTextIterator when collectUIMessages is true', async () => { - const mockModel = createMockModel(); + it('should produce compound string when provider already contains a slash', async () => { + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}); + + const v2Model: LanguageModelV2 = { + specificationVersion: 'v2' as const, + provider: 'my-org/anthropic', + modelId: 'claude-opus', + doGenerate: vi.fn(), + doStream: vi.fn(), + supportedUrls: {}, + }; const agent = new DurableAgent({ - model: async () => mockModel, + model: v2Model, tools: {}, }); @@ -1946,44 +1943,66 @@ describe('DurableAgent', () => { }); const { streamTextIterator } = await import('./stream-text-iterator.js'); - let capturedCollectUIChunks: boolean | undefined; const mockIterator = { next: vi.fn().mockResolvedValueOnce({ done: true, value: [] }), }; - vi.mocked(streamTextIterator).mockImplementation((opts) => { - capturedCollectUIChunks = opts.collectUIChunks; - return mockIterator as unknown as MockIterator; - }); + vi.mocked(streamTextIterator).mockReturnValue( + mockIterator as unknown as MockIterator + ); - const result = await agent.stream({ + await agent.stream({ messages: [{ role: 'user', content: 'test' }], writable: mockWritable, - collectUIMessages: true, }); - // When collectUIMessages is true, collectUIChunks should be passed to streamTextIterator - expect(capturedCollectUIChunks).toBe(true); + // The compound string includes both slashes — callers should be + // aware that provider names containing '/' produce multi-segment IDs + expect(streamTextIterator).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'my-org/anthropic/claude-opus', + }) + ); - // uiMessages should be defined (even if empty, since we're mocking) - expect(result.uiMessages).toBeDefined(); - expect(Array.isArray(result.uiMessages)).toBe(true); + consoleWarnSpy.mockRestore(); }); + }); + + describe('factory function deprecation', () => { + it('should emit deprecation warning for factory function model', () => { + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}); - it('should work correctly when collectUIMessages is true and sendFinish is false', async () => { const mockModel = createMockModel(); - const agent = new DurableAgent({ + new DurableAgent({ model: async () => mockModel, tools: {}, }); - const writtenChunks: unknown[] = []; - const closeFn = vi.fn(); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('Factory function model is deprecated') + ); + + consoleWarnSpy.mockRestore(); + }); + }); + + describe('instructions alias', () => { + it('should set system from instructions in constructor', async () => { + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}); + + const agent = new DurableAgent({ + model: 'test/test-model', + tools: {}, + instructions: 'You are a helpful assistant.', + }); + const mockWritable = new WritableStream({ - write: (chunk) => { - writtenChunks.push(chunk); - }, - close: closeFn, + write: vi.fn(), + close: vi.fn(), }); const { streamTextIterator } = await import('./stream-text-iterator.js'); @@ -1994,31 +2013,680 @@ describe('DurableAgent', () => { mockIterator as unknown as MockIterator ); - const result = await agent.stream({ + await agent.stream({ messages: [{ role: 'user', content: 'test' }], writable: mockWritable, - collectUIMessages: true, - sendFinish: false, }); - // uiMessages should still be defined even when sendFinish is false - expect(result.uiMessages).toBeDefined(); - expect(Array.isArray(result.uiMessages)).toBe(true); + // The system prompt should include the instructions text + // We verify this by checking the prompt passed to streamTextIterator + expect(streamTextIterator).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: expect.arrayContaining([ + expect.objectContaining({ + role: 'system', + content: 'You are a helpful assistant.', + }), + ]), + }) + ); - // The original writable should have been closed (since preventClose defaults to false) - expect(closeFn).toHaveBeenCalled(); + consoleWarnSpy.mockRestore(); + }); - // No finish chunk should have been written to the client - expect( - writtenChunks.find((c: any) => c.type === 'finish') - ).toBeUndefined(); + it('should prefer instructions over system when both provided', async () => { + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}); + + const agent = new DurableAgent({ + model: 'test/test-model', + tools: {}, + system: 'system prompt', + instructions: 'instructions prompt', + }); + + const mockWritable = new WritableStream({ + write: vi.fn(), + close: vi.fn(), + }); + + const { streamTextIterator } = await import('./stream-text-iterator.js'); + const mockIterator = { + next: vi.fn().mockResolvedValueOnce({ done: true, value: [] }), + }; + vi.mocked(streamTextIterator).mockReturnValue( + mockIterator as unknown as MockIterator + ); + + await agent.stream({ + messages: [{ role: 'user', content: 'test' }], + writable: mockWritable, + }); + + // instructions takes precedence over system + expect(streamTextIterator).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: expect.arrayContaining([ + expect.objectContaining({ + role: 'system', + content: 'instructions prompt', + }), + ]), + }) + ); + + consoleWarnSpy.mockRestore(); }); - it('should not write finish chunk but still return uiMessages when sendFinish is false', async () => { - const mockModel = createMockModel(); + it('should allow stream options.instructions to override constructor system', async () => { + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}); const agent = new DurableAgent({ - model: async () => mockModel, + model: 'test/test-model', + tools: {}, + system: 'constructor system', + }); + + const mockWritable = new WritableStream({ + write: vi.fn(), + close: vi.fn(), + }); + + const { streamTextIterator } = await import('./stream-text-iterator.js'); + const mockIterator = { + next: vi.fn().mockResolvedValueOnce({ done: true, value: [] }), + }; + vi.mocked(streamTextIterator).mockReturnValue( + mockIterator as unknown as MockIterator + ); + + await agent.stream({ + messages: [{ role: 'user', content: 'test' }], + writable: mockWritable, + instructions: 'stream instructions', + }); + + // stream instructions should override constructor system + expect(streamTextIterator).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: expect.arrayContaining([ + expect.objectContaining({ + role: 'system', + content: 'stream instructions', + }), + ]), + }) + ); + + consoleWarnSpy.mockRestore(); + }); + + it('should allow stream options.instructions to override constructor instructions', async () => { + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}); + + const agent = new DurableAgent({ + model: 'test/test-model', + tools: {}, + instructions: 'constructor instructions', + }); + + const mockWritable = new WritableStream({ + write: vi.fn(), + close: vi.fn(), + }); + + const { streamTextIterator } = await import('./stream-text-iterator.js'); + const mockIterator = { + next: vi.fn().mockResolvedValueOnce({ done: true, value: [] }), + }; + vi.mocked(streamTextIterator).mockReturnValue( + mockIterator as unknown as MockIterator + ); + + await agent.stream({ + messages: [{ role: 'user', content: 'test' }], + writable: mockWritable, + instructions: 'stream instructions', + }); + + // stream instructions should override constructor instructions + expect(streamTextIterator).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: expect.arrayContaining([ + expect.objectContaining({ + role: 'system', + content: 'stream instructions', + }), + ]), + }) + ); + + consoleWarnSpy.mockRestore(); + }); + }); + + describe('tool result passthrough', () => { + it('should pass through typed ToolResultOutput without re-wrapping', async () => { + const contentResult = { + type: 'content' as const, + value: [{ type: 'text', text: 'hello' }], + }; + const tools: ToolSet = { + testTool: { + description: 'A test tool', + inputSchema: z.object({}), + execute: async () => contentResult, + }, + }; + + const agent = new DurableAgent({ + model: TEST_MODEL, + tools, + }); + + const mockWritable = new WritableStream({ + write: vi.fn(), + close: vi.fn(), + }); + + const { streamTextIterator } = await import('./stream-text-iterator.js'); + const mockMessages: LanguageModelV2Prompt = [ + { role: 'user', content: [{ type: 'text', text: 'test' }] }, + ]; + const mockIterator = { + next: vi + .fn() + .mockResolvedValueOnce({ + done: false, + value: { + toolCalls: [ + { + toolCallId: 'test-call-id', + toolName: 'testTool', + input: '{}', + } as LanguageModelV2ToolCall, + ], + messages: mockMessages, + }, + }) + .mockResolvedValueOnce({ done: true, value: [] }), + }; + vi.mocked(streamTextIterator).mockReturnValue( + mockIterator as unknown as MockIterator + ); + + await agent.stream({ + messages: [{ role: 'user', content: 'test' }], + writable: mockWritable, + }); + + // Verify the typed result was passed through without being wrapped in {type:'json'} + const toolResultsCall = mockIterator.next.mock.calls[1][0]; + expect(toolResultsCall[0]).toMatchObject({ + type: 'tool-result', + toolCallId: 'test-call-id', + toolName: 'testTool', + output: contentResult, + }); + }); + + it('should still wrap plain objects as json', async () => { + const plainResult = { data: 'some value' }; + const tools: ToolSet = { + testTool: { + description: 'A test tool', + inputSchema: z.object({}), + execute: async () => plainResult, + }, + }; + + const agent = new DurableAgent({ + model: TEST_MODEL, + tools, + }); + + const mockWritable = new WritableStream({ + write: vi.fn(), + close: vi.fn(), + }); + + const { streamTextIterator } = await import('./stream-text-iterator.js'); + const mockMessages: LanguageModelV2Prompt = [ + { role: 'user', content: [{ type: 'text', text: 'test' }] }, + ]; + const mockIterator = { + next: vi + .fn() + .mockResolvedValueOnce({ + done: false, + value: { + toolCalls: [ + { + toolCallId: 'test-call-id', + toolName: 'testTool', + input: '{}', + } as LanguageModelV2ToolCall, + ], + messages: mockMessages, + }, + }) + .mockResolvedValueOnce({ done: true, value: [] }), + }; + vi.mocked(streamTextIterator).mockReturnValue( + mockIterator as unknown as MockIterator + ); + + await agent.stream({ + messages: [{ role: 'user', content: 'test' }], + writable: mockWritable, + }); + + // Plain objects should be wrapped with {type:'json'} + const toolResultsCall = mockIterator.next.mock.calls[1][0]; + expect(toolResultsCall[0]).toMatchObject({ + type: 'tool-result', + output: { + type: 'json', + value: plainResult, + }, + }); + }); + + it('should still wrap strings as text', async () => { + const tools: ToolSet = { + testTool: { + description: 'A test tool', + inputSchema: z.object({}), + execute: async () => 'hello world', + }, + }; + + const agent = new DurableAgent({ + model: TEST_MODEL, + tools, + }); + + const mockWritable = new WritableStream({ + write: vi.fn(), + close: vi.fn(), + }); + + const { streamTextIterator } = await import('./stream-text-iterator.js'); + const mockMessages: LanguageModelV2Prompt = [ + { role: 'user', content: [{ type: 'text', text: 'test' }] }, + ]; + const mockIterator = { + next: vi + .fn() + .mockResolvedValueOnce({ + done: false, + value: { + toolCalls: [ + { + toolCallId: 'test-call-id', + toolName: 'testTool', + input: '{}', + } as LanguageModelV2ToolCall, + ], + messages: mockMessages, + }, + }) + .mockResolvedValueOnce({ done: true, value: [] }), + }; + vi.mocked(streamTextIterator).mockReturnValue( + mockIterator as unknown as MockIterator + ); + + await agent.stream({ + messages: [{ role: 'user', content: 'test' }], + writable: mockWritable, + }); + + // String results should be wrapped with {type:'text'} + const toolResultsCall = mockIterator.next.mock.calls[1][0]; + expect(toolResultsCall[0]).toMatchObject({ + type: 'tool-result', + output: { + type: 'text', + value: 'hello world', + }, + }); + }); + + it('should pass through error-text ToolResultOutput without re-wrapping', async () => { + const errorTextResult = { + type: 'error-text' as const, + value: 'Something went wrong', + }; + const tools: ToolSet = { + testTool: { + description: 'A test tool', + inputSchema: z.object({}), + execute: async () => errorTextResult, + }, + }; + + const agent = new DurableAgent({ + model: TEST_MODEL, + tools, + }); + + const mockWritable = new WritableStream({ + write: vi.fn(), + close: vi.fn(), + }); + + const { streamTextIterator } = await import('./stream-text-iterator.js'); + const mockMessages: LanguageModelV2Prompt = [ + { role: 'user', content: [{ type: 'text', text: 'test' }] }, + ]; + const mockIterator = { + next: vi + .fn() + .mockResolvedValueOnce({ + done: false, + value: { + toolCalls: [ + { + toolCallId: 'test-call-id', + toolName: 'testTool', + input: '{}', + } as LanguageModelV2ToolCall, + ], + messages: mockMessages, + }, + }) + .mockResolvedValueOnce({ done: true, value: [] }), + }; + vi.mocked(streamTextIterator).mockReturnValue( + mockIterator as unknown as MockIterator + ); + + await agent.stream({ + messages: [{ role: 'user', content: 'test' }], + writable: mockWritable, + }); + + const toolResultsCall = mockIterator.next.mock.calls[1][0]; + expect(toolResultsCall[0]).toMatchObject({ + type: 'tool-result', + toolCallId: 'test-call-id', + toolName: 'testTool', + output: errorTextResult, + }); + }); + + it('should pass through error-json ToolResultOutput without re-wrapping', async () => { + const errorJsonResult = { + type: 'error-json' as const, + value: { code: 404, message: 'Not found' }, + }; + const tools: ToolSet = { + testTool: { + description: 'A test tool', + inputSchema: z.object({}), + execute: async () => errorJsonResult, + }, + }; + + const agent = new DurableAgent({ + model: TEST_MODEL, + tools, + }); + + const mockWritable = new WritableStream({ + write: vi.fn(), + close: vi.fn(), + }); + + const { streamTextIterator } = await import('./stream-text-iterator.js'); + const mockMessages: LanguageModelV2Prompt = [ + { role: 'user', content: [{ type: 'text', text: 'test' }] }, + ]; + const mockIterator = { + next: vi + .fn() + .mockResolvedValueOnce({ + done: false, + value: { + toolCalls: [ + { + toolCallId: 'test-call-id', + toolName: 'testTool', + input: '{}', + } as LanguageModelV2ToolCall, + ], + messages: mockMessages, + }, + }) + .mockResolvedValueOnce({ done: true, value: [] }), + }; + vi.mocked(streamTextIterator).mockReturnValue( + mockIterator as unknown as MockIterator + ); + + await agent.stream({ + messages: [{ role: 'user', content: 'test' }], + writable: mockWritable, + }); + + const toolResultsCall = mockIterator.next.mock.calls[1][0]; + expect(toolResultsCall[0]).toMatchObject({ + type: 'tool-result', + toolCallId: 'test-call-id', + toolName: 'testTool', + output: errorJsonResult, + }); + }); + + it('should pass through execution-denied ToolResultOutput without re-wrapping', async () => { + const deniedResult = { + type: 'execution-denied' as const, + reason: 'User denied tool execution', + }; + const tools: ToolSet = { + testTool: { + description: 'A test tool', + inputSchema: z.object({}), + execute: async () => deniedResult, + }, + }; + + const agent = new DurableAgent({ + model: TEST_MODEL, + tools, + }); + + const mockWritable = new WritableStream({ + write: vi.fn(), + close: vi.fn(), + }); + + const { streamTextIterator } = await import('./stream-text-iterator.js'); + const mockMessages: LanguageModelV2Prompt = [ + { role: 'user', content: [{ type: 'text', text: 'test' }] }, + ]; + const mockIterator = { + next: vi + .fn() + .mockResolvedValueOnce({ + done: false, + value: { + toolCalls: [ + { + toolCallId: 'test-call-id', + toolName: 'testTool', + input: '{}', + } as LanguageModelV2ToolCall, + ], + messages: mockMessages, + }, + }) + .mockResolvedValueOnce({ done: true, value: [] }), + }; + vi.mocked(streamTextIterator).mockReturnValue( + mockIterator as unknown as MockIterator + ); + + await agent.stream({ + messages: [{ role: 'user', content: 'test' }], + writable: mockWritable, + }); + + const toolResultsCall = mockIterator.next.mock.calls[1][0]; + expect(toolResultsCall[0]).toMatchObject({ + type: 'tool-result', + toolCallId: 'test-call-id', + toolName: 'testTool', + output: deniedResult, + }); + }); + }); + + describe('collectUIMessages', () => { + it('should return undefined uiMessages when collectUIMessages is false', async () => { + const agent = new DurableAgent({ + model: TEST_MODEL, + tools: {}, + }); + + const mockWritable = new WritableStream({ + write: vi.fn(), + close: vi.fn(), + }); + + const { streamTextIterator } = await import('./stream-text-iterator.js'); + const mockIterator = { + next: vi.fn().mockResolvedValueOnce({ done: true, value: [] }), + }; + vi.mocked(streamTextIterator).mockReturnValue( + mockIterator as unknown as MockIterator + ); + + const result = await agent.stream({ + messages: [{ role: 'user', content: 'test' }], + writable: mockWritable, + collectUIMessages: false, + }); + + expect(result.uiMessages).toBeUndefined(); + }); + + it('should return undefined uiMessages when collectUIMessages is not set', async () => { + const agent = new DurableAgent({ + model: TEST_MODEL, + tools: {}, + }); + + const mockWritable = new WritableStream({ + write: vi.fn(), + close: vi.fn(), + }); + + const { streamTextIterator } = await import('./stream-text-iterator.js'); + const mockIterator = { + next: vi.fn().mockResolvedValueOnce({ done: true, value: [] }), + }; + vi.mocked(streamTextIterator).mockReturnValue( + mockIterator as unknown as MockIterator + ); + + const result = await agent.stream({ + messages: [{ role: 'user', content: 'test' }], + writable: mockWritable, + }); + + expect(result.uiMessages).toBeUndefined(); + }); + + it('should pass collectUIChunks to streamTextIterator when collectUIMessages is true', async () => { + const agent = new DurableAgent({ + model: TEST_MODEL, + tools: {}, + }); + + const mockWritable = new WritableStream({ + write: vi.fn(), + close: vi.fn(), + }); + + const { streamTextIterator } = await import('./stream-text-iterator.js'); + let capturedCollectUIChunks: boolean | undefined; + const mockIterator = { + next: vi.fn().mockResolvedValueOnce({ done: true, value: [] }), + }; + vi.mocked(streamTextIterator).mockImplementation((opts) => { + capturedCollectUIChunks = opts.collectUIChunks; + return mockIterator as unknown as MockIterator; + }); + + const result = await agent.stream({ + messages: [{ role: 'user', content: 'test' }], + writable: mockWritable, + collectUIMessages: true, + }); + + // When collectUIMessages is true, collectUIChunks should be passed to streamTextIterator + expect(capturedCollectUIChunks).toBe(true); + + // uiMessages should be defined (even if empty, since we're mocking) + expect(result.uiMessages).toBeDefined(); + expect(Array.isArray(result.uiMessages)).toBe(true); + }); + + it('should work correctly when collectUIMessages is true and sendFinish is false', async () => { + const agent = new DurableAgent({ + model: TEST_MODEL, + tools: {}, + }); + + const writtenChunks: unknown[] = []; + const closeFn = vi.fn(); + const mockWritable = new WritableStream({ + write: (chunk) => { + writtenChunks.push(chunk); + }, + close: closeFn, + }); + + const { streamTextIterator } = await import('./stream-text-iterator.js'); + const mockIterator = { + next: vi.fn().mockResolvedValueOnce({ done: true, value: [] }), + }; + vi.mocked(streamTextIterator).mockReturnValue( + mockIterator as unknown as MockIterator + ); + + const result = await agent.stream({ + messages: [{ role: 'user', content: 'test' }], + writable: mockWritable, + collectUIMessages: true, + sendFinish: false, + }); + + // uiMessages should still be defined even when sendFinish is false + expect(result.uiMessages).toBeDefined(); + expect(Array.isArray(result.uiMessages)).toBe(true); + + // The original writable should have been closed (since preventClose defaults to false) + expect(closeFn).toHaveBeenCalled(); + + // No finish chunk should have been written to the client + expect( + writtenChunks.find( + (c) => (c as Record).type === 'finish' + ) + ).toBeUndefined(); + }); + + it('should not write finish chunk but still return uiMessages when sendFinish is false', async () => { + const agent = new DurableAgent({ + model: TEST_MODEL, tools: {}, }); diff --git a/packages/ai/src/agent/durable-agent.ts b/packages/ai/src/agent/durable-agent.ts index 6de6205a5..1d1464527 100644 --- a/packages/ai/src/agent/durable-agent.ts +++ b/packages/ai/src/agent/durable-agent.ts @@ -24,11 +24,31 @@ import { } from 'ai'; import { convertToLanguageModelPrompt, standardizePrompt } from 'ai/internal'; import { FatalError } from 'workflow'; +import { filterToolSet } from './filter-tools.js'; +import { addUsage, normalizeUsage, type NormalizedUsage } from './normalize.js'; import { streamTextIterator } from './stream-text-iterator.js'; import type { CompatibleLanguageModel } from './types.js'; +const TOOL_RESULT_OUTPUT_TYPES = new Set([ + 'text', + 'json', + 'content', + 'error-text', + 'error-json', + 'execution-denied', +]); + +function isToolResultOutput( + result: unknown +): result is LanguageModelV2ToolResultPart['output'] { + if (typeof result !== 'object' || result === null) return false; + const type = (result as Record).type; + return typeof type === 'string' && TOOL_RESULT_OUTPUT_TYPES.has(type); +} + // Re-export for consumers export type { CompatibleLanguageModel } from './types.js'; +export type { NormalizedUsage } from './normalize.js'; /** * Re-export the Output helper for structured output specifications. @@ -215,8 +235,7 @@ export interface GenerationSettings { */ export interface PrepareStepInfo { /** - * The current model configuration (string or function). - * The function should return a LanguageModel instance (V2 or V3 depending on AI SDK version). + * The current model configuration (string or factory function). */ model: string | (() => Promise); @@ -249,7 +268,6 @@ export interface PrepareStepInfo { export interface PrepareStepResult extends Partial { /** * Override the model for this step. - * The function should return a LanguageModel instance (V2 or V3 depending on AI SDK version). */ model?: string | (() => Promise); @@ -295,12 +313,22 @@ export type PrepareStepCallback = ( */ export interface DurableAgentOptions extends GenerationSettings { /** - * The model provider to use for the agent. + * The model to use for the agent. * - * This should be a string compatible with the Vercel AI Gateway (e.g., 'anthropic/claude-opus'), - * or a step function that returns a LanguageModel instance (V2 or V3 depending on AI SDK version). + * Accepts three forms: + * - **String** (recommended): A model ID compatible with the Vercel AI Gateway + * (e.g., `'anthropic/claude-opus'`). + * - **Model object**: A `LanguageModelV2` (AI SDK v5) or `LanguageModelV3` + * (AI SDK v6) instance. Automatically converted to a `'provider/modelId'` + * string for step boundary serialization. Middleware and wrappers are not + * preserved — use `providerOptions` instead. + * - **Factory function** (deprecated): A function returning a `LanguageModel` promise. + * Does not work in workflow mode due to step boundary serialization. */ - model: string | (() => Promise); + model: + | string + | CompatibleLanguageModel + | (() => Promise); /** * A set of tools available to the agent. @@ -314,6 +342,9 @@ export interface DurableAgentOptions extends GenerationSettings { */ system?: string; + /** Alias for system. If both are provided, instructions takes precedence. */ + instructions?: string; + /** * The tool choice strategy. Default: 'auto'. */ @@ -342,6 +373,11 @@ export type StreamTextOnFinishCallback< */ readonly messages: ModelMessage[]; + /** + * Total token usage aggregated across all steps. + */ + readonly totalUsage: NormalizedUsage; + /** * Context that is passed into tool execution. */ @@ -390,6 +426,9 @@ export interface DurableAgentStreamOptions< */ system?: string; + /** Alias for system. If both are provided, instructions takes precedence. */ + instructions?: string; + /** * The stream to which the agent writes message chunks. For example, use `getWritable()` to write to the workflow's default output stream. */ @@ -625,9 +664,9 @@ export class DurableAgent { private telemetry?: TelemetrySettings; constructor(options: DurableAgentOptions & { tools?: TBaseTools }) { - this.model = options.model; + this.model = resolveModelId(options.model); this.tools = (options.tools ?? {}) as TBaseTools; - this.system = options.system; + this.system = options.instructions ?? options.system; this.toolChoice = options.toolChoice as ToolChoice; this.telemetry = options.experimental_telemetry; @@ -660,12 +699,18 @@ export class DurableAgent { options: DurableAgentStreamOptions ): Promise> { const prompt = await standardizePrompt({ - system: options.system || this.system, + // Using ?? (nullish) instead of || (falsy) so empty string '' is a valid system prompt + system: options.instructions ?? options.system ?? this.system, messages: options.messages, }); const modelPrompt = await convertToLanguageModelPrompt({ prompt, + // The model's supportedUrls cannot be resolved here because the model + // is instantiated inside the step boundary (doStreamStep). Passing {} + // is the safe fallback: all URLs will be downloaded and inlined rather + // than passed natively to the provider. This is correct but slightly + // less efficient for providers that support native URL handling. supportedUrls: {}, download: options.experimental_download, }); @@ -709,7 +754,7 @@ export class DurableAgent { // Filter tools if activeTools is specified const effectiveTools = options.activeTools && options.activeTools.length > 0 - ? filterTools(this.tools, options.activeTools as string[]) + ? filterToolSet(this.tools, options.activeTools as string[]) : this.tools; // Initialize context @@ -742,9 +787,17 @@ export class DurableAgent { stopConditions: options.stopWhen, maxSteps: options.maxSteps, sendStart: options.sendStart ?? true, - onStepFinish: options.onStepFinish, + // streamTextIterator operates on ToolSet (the base type) internally. + // TTools extends ToolSet, so the callbacks are compatible at runtime — + // the cast bridges the generic parameter variance that TypeScript + // cannot verify structurally across the function boundary. + onStepFinish: options.onStepFinish as + | StreamTextOnStepFinishCallback + | undefined, onError: options.onError, - prepareStep: options.prepareStep, + prepareStep: options.prepareStep as + | PrepareStepCallback + | undefined, generationSettings: mergedGenerationSettings, toolChoice: effectiveToolChoice as ToolChoice, experimental_context: experimentalContext, @@ -963,9 +1016,14 @@ export class DurableAgent { // Call onFinish callback if provided (always call, even on errors, but not on abort) if (options.onFinish && !wasAborted) { + const totalUsage = steps.reduce( + (acc, step) => addUsage(acc, normalizeUsage(step.usage)), + normalizeUsage(undefined) + ); await options.onFinish({ steps, messages: messages as ModelMessage[], + totalUsage, experimental_context: experimentalContext, experimental_output: experimentalOutput, }); @@ -992,19 +1050,33 @@ export class DurableAgent { } /** - * Filter tools to only include the specified active tools. + * Resolve a model option to the internal representation. + * String and function models pass through as-is. Model objects are converted + * to a 'provider/modelId' string for step boundary serialization. */ -function filterTools( - tools: TTools, - activeTools: string[] -): ToolSet { - const filtered: ToolSet = {}; - for (const toolName of activeTools) { - if (toolName in tools) { - filtered[toolName] = tools[toolName]; - } +function resolveModelId( + model: + | string + | CompatibleLanguageModel + | (() => Promise) +): string | (() => Promise) { + if (typeof model === 'string') { + return model; } - return filtered; + if (typeof model === 'function') { + console.warn( + `[DurableAgent] Factory function model is deprecated and will not work in workflow mode. ` + + `Use a string model ID or a LanguageModel object instead.` + ); + return model; + } + // Model object — extract provider/modelId for gateway resolution inside the step + console.warn( + `[DurableAgent] Model object "${model.provider}/${model.modelId}" was converted to ` + + `a string for step boundary serialization. Middleware and wrappers are not preserved. ` + + `Use providerOptions to configure provider-specific settings.` + ); + return `${model.provider}/${model.modelId}`; } async function writeFinishChunk(writable: WritableStream) { @@ -1170,6 +1242,16 @@ async function executeTool( experimental_context: experimentalContext, }); + // Pass through typed ToolResultOutput (content, error-text, etc.) without re-wrapping + if (isToolResultOutput(toolResult)) { + return { + type: 'tool-result' as const, + toolCallId: toolCall.toolCallId, + toolName: toolCall.toolName, + output: toolResult, + }; + } + // Use the appropriate output type based on the result // AI SDK supports 'text' for strings and 'json' for objects const output = diff --git a/packages/ai/src/agent/filter-tools.ts b/packages/ai/src/agent/filter-tools.ts new file mode 100644 index 000000000..0243c52ff --- /dev/null +++ b/packages/ai/src/agent/filter-tools.ts @@ -0,0 +1,14 @@ +import type { ToolSet } from 'ai'; + +/** + * Filter a tool set to only include the specified active tools. + */ +export function filterToolSet(tools: ToolSet, activeTools: string[]): ToolSet { + const filtered: ToolSet = {}; + for (const toolName of activeTools) { + if (toolName in tools) { + filtered[toolName] = tools[toolName]; + } + } + return filtered; +} diff --git a/packages/ai/src/agent/normalize.test.ts b/packages/ai/src/agent/normalize.test.ts new file mode 100644 index 000000000..fcb88fbdf --- /dev/null +++ b/packages/ai/src/agent/normalize.test.ts @@ -0,0 +1,574 @@ +import { describe, expect, it } from 'vitest'; +import { + addUsage, + normalizeFinishReason, + normalizeUsage, + type NormalizedUsage, +} from './normalize.js'; + +const EMPTY_INPUT_DETAILS = { + noCacheTokens: undefined, + cacheReadTokens: undefined, + cacheWriteTokens: undefined, +}; + +const EMPTY_OUTPUT_DETAILS = { + textTokens: undefined, + reasoningTokens: undefined, +}; + +describe('normalizeFinishReason', () => { + describe('null/undefined', () => { + it('should return "unknown" for null', () => { + expect(normalizeFinishReason(null)).toEqual({ + finishReason: 'unknown', + rawFinishReason: undefined, + }); + }); + + it('should return "unknown" for undefined', () => { + expect(normalizeFinishReason(undefined)).toEqual({ + finishReason: 'unknown', + rawFinishReason: undefined, + }); + }); + }); + + describe('string passthrough', () => { + it('should pass through "stop"', () => { + expect(normalizeFinishReason('stop')).toEqual({ + finishReason: 'stop', + rawFinishReason: 'stop', + }); + }); + + it('should pass through "tool-calls"', () => { + expect(normalizeFinishReason('tool-calls')).toEqual({ + finishReason: 'tool-calls', + rawFinishReason: 'tool-calls', + }); + }); + + it('should pass through "length"', () => { + expect(normalizeFinishReason('length')).toEqual({ + finishReason: 'length', + rawFinishReason: 'length', + }); + }); + + it('should pass through "content-filter"', () => { + expect(normalizeFinishReason('content-filter')).toEqual({ + finishReason: 'content-filter', + rawFinishReason: 'content-filter', + }); + }); + + it('should pass through "error"', () => { + expect(normalizeFinishReason('error')).toEqual({ + finishReason: 'error', + rawFinishReason: 'error', + }); + }); + + it('should pass through "unknown"', () => { + expect(normalizeFinishReason('unknown')).toEqual({ + finishReason: 'unknown', + rawFinishReason: 'unknown', + }); + }); + + it('should return "unknown" for empty string', () => { + expect(normalizeFinishReason('')).toEqual({ + finishReason: 'unknown', + rawFinishReason: undefined, + }); + }); + }); + + describe('V3 format ({unified, raw})', () => { + it('should extract "stop" from V3 format', () => { + expect( + normalizeFinishReason({ unified: 'stop', raw: 'stop_sequence' }) + ).toEqual({ + finishReason: 'stop', + rawFinishReason: 'stop_sequence', + }); + }); + + it('should extract "tool-calls" from V3 format', () => { + expect( + normalizeFinishReason({ unified: 'tool-calls', raw: 'tool_use' }) + ).toEqual({ + finishReason: 'tool-calls', + rawFinishReason: 'tool_use', + }); + }); + + it('should extract "length" from V3 format', () => { + expect( + normalizeFinishReason({ unified: 'length', raw: 'max_tokens' }) + ).toEqual({ + finishReason: 'length', + rawFinishReason: 'max_tokens', + }); + }); + }); + + describe('V2 object fallback ({type})', () => { + it('should extract "stop" from V2 object', () => { + expect(normalizeFinishReason({ type: 'stop' })).toEqual({ + finishReason: 'stop', + rawFinishReason: undefined, + }); + }); + + it('should extract "tool-calls" from V2 object', () => { + expect(normalizeFinishReason({ type: 'tool-calls' })).toEqual({ + finishReason: 'tool-calls', + rawFinishReason: undefined, + }); + }); + + it('should extract "length" from V2 object', () => { + expect(normalizeFinishReason({ type: 'length' })).toEqual({ + finishReason: 'length', + rawFinishReason: undefined, + }); + }); + + it('should extract "content-filter" from V2 object', () => { + expect(normalizeFinishReason({ type: 'content-filter' })).toEqual({ + finishReason: 'content-filter', + rawFinishReason: undefined, + }); + }); + + it('should extract "error" from V2 object', () => { + expect(normalizeFinishReason({ type: 'error' })).toEqual({ + finishReason: 'error', + rawFinishReason: undefined, + }); + }); + + it('should extract "other" from V2 object', () => { + expect(normalizeFinishReason({ type: 'other' })).toEqual({ + finishReason: 'other', + rawFinishReason: undefined, + }); + }); + + it('should extract "unknown" from V2 object', () => { + expect(normalizeFinishReason({ type: 'unknown' })).toEqual({ + finishReason: 'unknown', + rawFinishReason: undefined, + }); + }); + + it('should return "unknown" for object with null type', () => { + expect(normalizeFinishReason({ type: null })).toEqual({ + finishReason: 'unknown', + rawFinishReason: undefined, + }); + }); + + it('should return "unknown" for object with undefined type', () => { + expect(normalizeFinishReason({ type: undefined })).toEqual({ + finishReason: 'unknown', + rawFinishReason: undefined, + }); + }); + + it('should handle object with additional properties', () => { + expect( + normalizeFinishReason({ + type: 'stop', + reason: 'end_turn', + metadata: { foo: 'bar' }, + }) + ).toEqual({ + finishReason: 'stop', + rawFinishReason: undefined, + }); + }); + + it('should extract raw from V2 object with raw field', () => { + expect(normalizeFinishReason({ type: 'stop', raw: 'end_turn' })).toEqual({ + finishReason: 'stop', + rawFinishReason: 'end_turn', + }); + }); + }); + + describe('edge cases', () => { + it('should return "unknown" for empty object', () => { + expect(normalizeFinishReason({})).toEqual({ + finishReason: 'unknown', + rawFinishReason: undefined, + }); + }); + + it('should return "unknown" for number', () => { + expect(normalizeFinishReason(42)).toEqual({ + finishReason: 'unknown', + rawFinishReason: undefined, + }); + }); + + it('should return "unknown" for boolean', () => { + expect(normalizeFinishReason(true)).toEqual({ + finishReason: 'unknown', + rawFinishReason: undefined, + }); + }); + + it('should return "unknown" for array', () => { + expect(normalizeFinishReason(['stop'])).toEqual({ + finishReason: 'unknown', + rawFinishReason: undefined, + }); + }); + }); + + describe('bug reproduction', () => { + it('should handle object format that caused [object Object] error', () => { + const normalized = normalizeFinishReason({ type: 'stop' }); + expect(normalized.finishReason).toBe('stop'); + expect(typeof normalized.finishReason).toBe('string'); + }); + + it('should handle tool-calls object format', () => { + const normalized = normalizeFinishReason({ type: 'tool-calls' }); + expect(normalized.finishReason).toBe('tool-calls'); + expect(typeof normalized.finishReason).toBe('string'); + }); + }); +}); + +describe('normalizeUsage', () => { + describe('null/undefined', () => { + it('should return zeroes for null', () => { + expect(normalizeUsage(null)).toEqual({ + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + inputTokenDetails: EMPTY_INPUT_DETAILS, + outputTokenDetails: EMPTY_OUTPUT_DETAILS, + }); + }); + + it('should return zeroes for undefined', () => { + expect(normalizeUsage(undefined)).toEqual({ + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + inputTokenDetails: EMPTY_INPUT_DETAILS, + outputTokenDetails: EMPTY_OUTPUT_DETAILS, + }); + }); + }); + + describe('V2 flat format', () => { + it('should pass through V2 usage with totalTokens', () => { + expect( + normalizeUsage({ inputTokens: 10, outputTokens: 20, totalTokens: 30 }) + ).toEqual({ + inputTokens: 10, + outputTokens: 20, + totalTokens: 30, + inputTokenDetails: EMPTY_INPUT_DETAILS, + outputTokenDetails: EMPTY_OUTPUT_DETAILS, + }); + }); + + it('should compute totalTokens when missing in V2 format', () => { + expect(normalizeUsage({ inputTokens: 10, outputTokens: 20 })).toEqual({ + inputTokens: 10, + outputTokens: 20, + totalTokens: 30, + inputTokenDetails: EMPTY_INPUT_DETAILS, + outputTokenDetails: EMPTY_OUTPUT_DETAILS, + }); + }); + + it('should preserve cachedInputTokens in inputTokenDetails', () => { + expect( + normalizeUsage({ + inputTokens: 100, + outputTokens: 50, + cachedInputTokens: 80, + }) + ).toEqual({ + inputTokens: 100, + outputTokens: 50, + totalTokens: 150, + inputTokenDetails: { + noCacheTokens: undefined, + cacheReadTokens: 80, + cacheWriteTokens: undefined, + }, + outputTokenDetails: EMPTY_OUTPUT_DETAILS, + }); + }); + + it('should preserve reasoningTokens in outputTokenDetails', () => { + expect( + normalizeUsage({ + inputTokens: 100, + outputTokens: 50, + reasoningTokens: 30, + }) + ).toEqual({ + inputTokens: 100, + outputTokens: 50, + totalTokens: 150, + inputTokenDetails: EMPTY_INPUT_DETAILS, + outputTokenDetails: { + textTokens: undefined, + reasoningTokens: 30, + }, + }); + }); + + it('should preserve raw field when present', () => { + const result = normalizeUsage({ + inputTokens: 10, + outputTokens: 20, + raw: { custom: 'data' }, + }); + expect(result.raw).toEqual({ custom: 'data' }); + }); + }); + + describe('V3 nested format', () => { + it('should extract totals from V3 nested usage', () => { + expect( + normalizeUsage({ + inputTokens: { total: 10 }, + outputTokens: { total: 20 }, + }) + ).toEqual({ + inputTokens: 10, + outputTokens: 20, + totalTokens: 30, + inputTokenDetails: EMPTY_INPUT_DETAILS, + outputTokenDetails: EMPTY_OUTPUT_DETAILS, + }); + }); + + it('should handle V3 partial usage (only inputTokens)', () => { + expect(normalizeUsage({ inputTokens: { total: 10 } })).toEqual({ + inputTokens: 10, + outputTokens: 0, + totalTokens: 10, + inputTokenDetails: EMPTY_INPUT_DETAILS, + outputTokenDetails: EMPTY_OUTPUT_DETAILS, + }); + }); + + it('should handle V3 partial usage (only outputTokens)', () => { + expect(normalizeUsage({ outputTokens: { total: 20 } })).toEqual({ + inputTokens: 0, + outputTokens: 20, + totalTokens: 20, + inputTokenDetails: EMPTY_INPUT_DETAILS, + outputTokenDetails: EMPTY_OUTPUT_DETAILS, + }); + }); + + it('should extract detailed breakdowns from V3 format', () => { + expect( + normalizeUsage({ + inputTokens: { + total: 100, + noCache: 20, + cacheRead: 60, + cacheWrite: 20, + }, + outputTokens: { total: 50, text: 40, reasoning: 10 }, + }) + ).toEqual({ + inputTokens: 100, + outputTokens: 50, + totalTokens: 150, + inputTokenDetails: { + noCacheTokens: 20, + cacheReadTokens: 60, + cacheWriteTokens: 20, + }, + outputTokenDetails: { + textTokens: 40, + reasoningTokens: 10, + }, + }); + }); + + it('should preserve raw field from V3 format', () => { + const result = normalizeUsage({ + inputTokens: { total: 10 }, + outputTokens: { total: 20 }, + raw: { provider_data: 'value' }, + }); + expect(result.raw).toEqual({ provider_data: 'value' }); + }); + }); + + describe('edge cases', () => { + it('should return zeroes for empty object', () => { + expect(normalizeUsage({})).toEqual({ + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + inputTokenDetails: EMPTY_INPUT_DETAILS, + outputTokenDetails: EMPTY_OUTPUT_DETAILS, + }); + }); + + it('should return zeroes for non-object', () => { + expect(normalizeUsage(42)).toEqual({ + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + inputTokenDetails: EMPTY_INPUT_DETAILS, + outputTokenDetails: EMPTY_OUTPUT_DETAILS, + }); + }); + + it('should return zeroes for array', () => { + expect(normalizeUsage([10, 20])).toEqual({ + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + inputTokenDetails: EMPTY_INPUT_DETAILS, + outputTokenDetails: EMPTY_OUTPUT_DETAILS, + }); + }); + + it('should return zeroes when only outputTokens is a number (V2 branch requires inputTokens)', () => { + expect(normalizeUsage({ outputTokens: 20 })).toEqual({ + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + inputTokenDetails: EMPTY_INPUT_DETAILS, + outputTokenDetails: EMPTY_OUTPUT_DETAILS, + }); + }); + }); +}); + +describe('addUsage', () => { + const emptyUsage: NormalizedUsage = { + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + inputTokenDetails: { + noCacheTokens: undefined, + cacheReadTokens: undefined, + cacheWriteTokens: undefined, + }, + outputTokenDetails: { + textTokens: undefined, + reasoningTokens: undefined, + }, + }; + + it('should add two usage objects with basic tokens', () => { + const a: NormalizedUsage = { + ...emptyUsage, + inputTokens: 10, + outputTokens: 20, + totalTokens: 30, + }; + const b: NormalizedUsage = { + ...emptyUsage, + inputTokens: 5, + outputTokens: 15, + totalTokens: 20, + }; + expect(addUsage(a, b)).toEqual({ + inputTokens: 15, + outputTokens: 35, + totalTokens: 50, + inputTokenDetails: EMPTY_INPUT_DETAILS, + outputTokenDetails: EMPTY_OUTPUT_DETAILS, + }); + }); + + it('should sum detail fields when both are present', () => { + const a: NormalizedUsage = { + inputTokens: 100, + outputTokens: 50, + totalTokens: 150, + inputTokenDetails: { + noCacheTokens: 20, + cacheReadTokens: 60, + cacheWriteTokens: 20, + }, + outputTokenDetails: { + textTokens: 40, + reasoningTokens: 10, + }, + }; + const b: NormalizedUsage = { + inputTokens: 80, + outputTokens: 30, + totalTokens: 110, + inputTokenDetails: { + noCacheTokens: 10, + cacheReadTokens: 50, + cacheWriteTokens: 20, + }, + outputTokenDetails: { + textTokens: 20, + reasoningTokens: 10, + }, + }; + expect(addUsage(a, b)).toEqual({ + inputTokens: 180, + outputTokens: 80, + totalTokens: 260, + inputTokenDetails: { + noCacheTokens: 30, + cacheReadTokens: 110, + cacheWriteTokens: 40, + }, + outputTokenDetails: { + textTokens: 60, + reasoningTokens: 20, + }, + }); + }); + + it('should keep undefined when both detail fields are undefined', () => { + expect(addUsage(emptyUsage, emptyUsage)).toEqual({ + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + inputTokenDetails: EMPTY_INPUT_DETAILS, + outputTokenDetails: EMPTY_OUTPUT_DETAILS, + }); + }); + + it('should treat undefined as 0 when only one side has a value', () => { + const a: NormalizedUsage = { + ...emptyUsage, + inputTokens: 10, + outputTokens: 5, + totalTokens: 15, + inputTokenDetails: { + ...emptyUsage.inputTokenDetails, + cacheReadTokens: 8, + }, + }; + expect(addUsage(a, emptyUsage)).toEqual({ + inputTokens: 10, + outputTokens: 5, + totalTokens: 15, + inputTokenDetails: { + noCacheTokens: undefined, + cacheReadTokens: 8, + cacheWriteTokens: undefined, + }, + outputTokenDetails: EMPTY_OUTPUT_DETAILS, + }); + }); +}); diff --git a/packages/ai/src/agent/normalize.ts b/packages/ai/src/agent/normalize.ts new file mode 100644 index 000000000..724487947 --- /dev/null +++ b/packages/ai/src/agent/normalize.ts @@ -0,0 +1,201 @@ +import type { JSONObject } from '@ai-sdk/provider'; +import type { FinishReason } from 'ai'; + +/** + * Result of normalizing a finish reason, containing both the unified reason + * and the optional raw provider string (V3 only). + * + * @internal + */ +export interface NormalizedFinishReason { + finishReason: FinishReason; + rawFinishReason: string | undefined; +} + +/** + * Normalize finish reason from V2 (string) or V3 ({unified, raw}) format. + * Returns both the unified finish reason and the raw provider string. + * + * @internal + */ +export function normalizeFinishReason(raw: unknown): NormalizedFinishReason { + if (raw == null) + return { finishReason: 'unknown', rawFinishReason: undefined }; + if (typeof raw === 'string') { + const finishReason = (raw === '' ? 'unknown' : raw) as FinishReason; + return { finishReason, rawFinishReason: raw || undefined }; + } + if (typeof raw === 'object' && !Array.isArray(raw)) { + const obj = raw as Record; + const rawValue = typeof obj.raw === 'string' ? obj.raw : undefined; + // V3: { unified: 'stop', raw: 'stop' } + if (typeof obj.unified === 'string') { + return { + finishReason: obj.unified as FinishReason, + rawFinishReason: rawValue, + }; + } + // V2 object fallback: { type: 'stop' } + if (typeof obj.type === 'string') { + return { + finishReason: obj.type as FinishReason, + rawFinishReason: rawValue, + }; + } + } + return { finishReason: 'unknown', rawFinishReason: undefined }; +} + +/** + * Normalized usage type that is a superset of both v5 (LanguageModelV2Usage) + * and v6 (LanguageModelUsage). V5 consumers see the flat fields they expect; + * v6 consumers see the detailed breakdowns and raw data. + */ +export interface NormalizedUsage { + inputTokens: number; + outputTokens: number; + totalTokens: number; + inputTokenDetails: { + noCacheTokens: number | undefined; + cacheReadTokens: number | undefined; + cacheWriteTokens: number | undefined; + }; + outputTokenDetails: { + textTokens: number | undefined; + reasoningTokens: number | undefined; + }; + raw?: JSONObject; +} + +const EMPTY_USAGE: NormalizedUsage = { + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + inputTokenDetails: { + noCacheTokens: undefined, + cacheReadTokens: undefined, + cacheWriteTokens: undefined, + }, + outputTokenDetails: { + textTokens: undefined, + reasoningTokens: undefined, + }, +}; + +/** + * Normalize usage from V2 (flat) or V3 (nested) format into a shape + * compatible with both v5 and v6 StepResult.usage. + * + * V5 (LanguageModelV2Usage): { inputTokens, outputTokens, totalTokens } + * V6 (LanguageModelUsage): adds inputTokenDetails, outputTokenDetails, raw + * + * The returned object includes all fields. V5 consumers ignore the extras. + * + * @internal + */ +export function normalizeUsage(raw: unknown): NormalizedUsage { + if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) { + return { ...EMPTY_USAGE }; + } + + const obj = raw as Record; + + // V2 format: flat numbers (may include deprecated cachedInputTokens/reasoningTokens) + if (typeof obj.inputTokens === 'number') { + const inputTokens = obj.inputTokens; + const outputTokens = (obj.outputTokens as number) ?? 0; + const totalTokens = + (obj.totalTokens as number) ?? inputTokens + outputTokens; + return { + inputTokens, + outputTokens, + totalTokens, + inputTokenDetails: { + noCacheTokens: undefined, + cacheReadTokens: (obj.cachedInputTokens as number) ?? undefined, + cacheWriteTokens: undefined, + }, + outputTokenDetails: { + textTokens: undefined, + reasoningTokens: (obj.reasoningTokens as number) ?? undefined, + }, + ...(obj.raw != null ? { raw: obj.raw as JSONObject } : {}), + }; + } + + // V3 format: nested objects with .total and detailed breakdowns + if ( + typeof obj.inputTokens === 'object' || + typeof obj.outputTokens === 'object' + ) { + const inputObj = obj.inputTokens as Record | undefined; + const outputObj = obj.outputTokens as Record | undefined; + const inputTokens = (inputObj?.total as number) ?? 0; + const outputTokens = (outputObj?.total as number) ?? 0; + return { + inputTokens, + outputTokens, + totalTokens: inputTokens + outputTokens, + inputTokenDetails: { + noCacheTokens: (inputObj?.noCache as number) ?? undefined, + cacheReadTokens: (inputObj?.cacheRead as number) ?? undefined, + cacheWriteTokens: (inputObj?.cacheWrite as number) ?? undefined, + }, + outputTokenDetails: { + textTokens: (outputObj?.text as number) ?? undefined, + reasoningTokens: (outputObj?.reasoning as number) ?? undefined, + }, + ...(obj.raw != null ? { raw: obj.raw as JSONObject } : {}), + }; + } + + return { ...EMPTY_USAGE }; +} + +/** + * Add two NormalizedUsage objects together, summing all token counts. + * + * @internal + */ +export function addUsage( + a: NormalizedUsage, + b: NormalizedUsage +): NormalizedUsage { + return { + inputTokens: a.inputTokens + b.inputTokens, + outputTokens: a.outputTokens + b.outputTokens, + totalTokens: a.totalTokens + b.totalTokens, + inputTokenDetails: { + noCacheTokens: addOptional( + a.inputTokenDetails.noCacheTokens, + b.inputTokenDetails.noCacheTokens + ), + cacheReadTokens: addOptional( + a.inputTokenDetails.cacheReadTokens, + b.inputTokenDetails.cacheReadTokens + ), + cacheWriteTokens: addOptional( + a.inputTokenDetails.cacheWriteTokens, + b.inputTokenDetails.cacheWriteTokens + ), + }, + outputTokenDetails: { + textTokens: addOptional( + a.outputTokenDetails.textTokens, + b.outputTokenDetails.textTokens + ), + reasoningTokens: addOptional( + a.outputTokenDetails.reasoningTokens, + b.outputTokenDetails.reasoningTokens + ), + }, + }; +} + +function addOptional( + a: number | undefined, + b: number | undefined +): number | undefined { + if (a == null && b == null) return undefined; + return (a ?? 0) + (b ?? 0); +} diff --git a/packages/ai/src/agent/stream-text-iterator.test.ts b/packages/ai/src/agent/stream-text-iterator.test.ts index 910429113..b76a1a57a 100644 --- a/packages/ai/src/agent/stream-text-iterator.test.ts +++ b/packages/ai/src/agent/stream-text-iterator.test.ts @@ -20,7 +20,9 @@ vi.mock('./do-stream-step.js', () => ({ })); // Import after mocking -const { streamTextIterator } = await import('./stream-text-iterator.js'); +const { streamTextIterator, getToolOutputForUI } = await import( + './stream-text-iterator.js' +); const { doStreamStep } = await import('./do-stream-step.js'); /** @@ -426,4 +428,54 @@ describe('streamTextIterator', () => { expect(toolWithoutMeta?.providerOptions).toBeUndefined(); }); }); + + describe('getToolOutputForUI', () => { + it('should extract value from text output', () => { + expect(getToolOutputForUI({ type: 'text', value: 'hello' })).toBe( + 'hello' + ); + }); + + it('should extract value from error-text output', () => { + expect(getToolOutputForUI({ type: 'error-text', value: 'oops' })).toBe( + 'oops' + ); + }); + + it('should extract value from json output', () => { + const val = { answer: 42 }; + expect(getToolOutputForUI({ type: 'json', value: val })).toBe(val); + }); + + it('should extract value from error-json output', () => { + const val = { code: 500 }; + expect(getToolOutputForUI({ type: 'error-json', value: val })).toBe(val); + }); + + it('should extract value from content output', () => { + const val = [{ type: 'text', text: 'hi' }]; + expect(getToolOutputForUI({ type: 'content', value: val })).toBe(val); + }); + + it('should return reason for execution-denied output', () => { + const output = { type: 'execution-denied', reason: 'User said no' }; + expect( + getToolOutputForUI(output as Parameters[0]) + ).toBe('User said no'); + }); + + it('should return default message for execution-denied without reason', () => { + const output = { type: 'execution-denied' }; + expect( + getToolOutputForUI(output as Parameters[0]) + ).toBe('Tool execution was denied'); + }); + + it('should return entire output for unknown types', () => { + const output = { type: 'future-type', data: 123 }; + expect( + getToolOutputForUI(output as Parameters[0]) + ).toBe(output); + }); + }); }); diff --git a/packages/ai/src/agent/stream-text-iterator.ts b/packages/ai/src/agent/stream-text-iterator.ts index eb7770d69..bc39720cb 100644 --- a/packages/ai/src/agent/stream-text-iterator.ts +++ b/packages/ai/src/agent/stream-text-iterator.ts @@ -5,7 +5,6 @@ import type { LanguageModelV2ToolResultPart, } from '@ai-sdk/provider'; import type { - FinishReason, StepResult, StreamTextOnStepFinishCallback, ToolChoice, @@ -24,6 +23,8 @@ import type { StreamTextTransform, TelemetrySettings, } from './durable-agent.js'; +import { filterToolSet } from './filter-tools.js'; +import { normalizeFinishReason } from './normalize.js'; import { toolsToModelTools } from './tools-to-model-tools.js'; import type { CompatibleLanguageModel } from './types.js'; @@ -77,9 +78,9 @@ export async function* streamTextIterator({ stopConditions?: ModelStopCondition[] | ModelStopCondition; maxSteps?: number; sendStart?: boolean; - onStepFinish?: StreamTextOnStepFinishCallback; + onStepFinish?: StreamTextOnStepFinishCallback; onError?: StreamTextOnErrorCallback; - prepareStep?: PrepareStepCallback; + prepareStep?: PrepareStepCallback; generationSettings?: GenerationSettings; toolChoice?: ToolChoice; experimental_context?: unknown; @@ -103,11 +104,11 @@ export async function* streamTextIterator({ let currentContext = experimental_context; let currentActiveTools: string[] | undefined; - const steps: StepResult[] = []; + const steps: StepResult[] = []; let done = false; let isFirstIteration = true; let stepNumber = 0; - let lastStep: StepResult | undefined; + let lastStep: StepResult | undefined; let lastStepWasToolCalls = false; let lastStepUIChunks: UIMessageChunk[] | undefined; let allAccumulatedUIChunks: UIMessageChunk[] = []; @@ -291,7 +292,7 @@ export async function* streamTextIterator({ ]; // Normalize finishReason - AI SDK v6 returns { unified, raw }, v5 returns a string - const finishReason = normalizeFinishReason(finish?.finishReason); + const { finishReason } = normalizeFinishReason(finish?.finishReason); if (finishReason === 'tool-calls') { lastStepWasToolCalls = true; @@ -421,6 +422,35 @@ export async function* streamTextIterator({ return conversationPrompt; } +/** + * Extract the display-friendly value from a LanguageModelV2ToolResultPart output. + * + * @internal Exported for testing. The default branch handles V3-only types + * (e.g., `execution-denied`) at runtime. When `@ai-sdk/provider` adds V3 + * output variants to its type definitions, replace the runtime cast with a + * proper union member. + */ +export function getToolOutputForUI( + output: LanguageModelV2ToolResultPart['output'] +): unknown { + switch (output.type) { + case 'text': + case 'error-text': + case 'json': + case 'error-json': + case 'content': + return output.value; + default: { + // Handle V3-only types (e.g., 'execution-denied') at runtime + const o = output as { type: string; reason?: string }; + if (o.type === 'execution-denied') { + return o.reason ?? 'Tool execution was denied'; + } + return output; + } + } +} + async function writeToolOutputToUI( writable: WritableStream, toolResults: LanguageModelV2ToolResultPart[], @@ -434,7 +464,7 @@ async function writeToolOutputToUI( const chunk: UIMessageChunk = { type: 'tool-output-available' as const, toolCallId: result.toolCallId, - output: result.output.value, + output: getToolOutputForUI(result.output), }; if (collectUIChunks) { chunks.push(chunk); @@ -446,31 +476,3 @@ async function writeToolOutputToUI( } return chunks; } - -/** - * Filter a tool set to only include the specified active tools. - */ -function filterToolSet(tools: ToolSet, activeTools: string[]): ToolSet { - const filtered: ToolSet = {}; - for (const toolName of activeTools) { - if (toolName in tools) { - filtered[toolName] = tools[toolName]; - } - } - return filtered; -} - -/** - * Normalize finishReason from different AI SDK versions. - * - AI SDK v6: returns { unified: 'tool-calls', raw: 'tool_use' } - * - AI SDK v5: returns 'tool-calls' string directly - */ -function normalizeFinishReason(raw: unknown): FinishReason | undefined { - if (raw == null) return undefined; - if (typeof raw === 'string') return raw as FinishReason; - if (typeof raw === 'object') { - const obj = raw as { unified?: FinishReason; type?: FinishReason }; - return obj.unified ?? obj.type ?? 'unknown'; - } - return undefined; -} diff --git a/packages/ai/src/agent/types.ts b/packages/ai/src/agent/types.ts index 5eeb2f9d7..1e5f738a2 100644 --- a/packages/ai/src/agent/types.ts +++ b/packages/ai/src/agent/types.ts @@ -1,23 +1,21 @@ /** * Shared types for AI SDK v5 and v6 compatibility. */ -import type { - LanguageModelV2, - LanguageModelV2CallOptions, - LanguageModelV2StreamPart, -} from '@ai-sdk/provider'; +import type { LanguageModelV2 } from '@ai-sdk/provider'; /** * Compatible language model type that works with both AI SDK v5 and v6. * * AI SDK v5 uses LanguageModelV2, while AI SDK v6 uses LanguageModelV3. - * Both have compatible `doStream` interfaces for our use case. + * DurableAgent converts model objects to `"provider/modelId"` strings for + * step boundary serialization (see {@link resolveModelId}), so only the + * identity properties are required from V3 models. * - * This type represents the union of both model versions, allowing code - * to work seamlessly with either AI SDK version. - * - * Note: V3 models accept LanguageModelV2CallOptions at runtime due to - * structural compatibility between V2 and V3 prompt/options formats. + * The V3 branch intentionally omits `doStream` because the V2 and V3 + * signatures are structurally incompatible at the TypeScript level + * (different CallOptions, StreamPart, FinishReason, and Usage shapes). + * Runtime V2/V3 differences are handled by {@link normalizeFinishReason} + * and {@link normalizeUsage} in normalize.ts. */ export type CompatibleLanguageModel = | LanguageModelV2 @@ -25,12 +23,34 @@ export type CompatibleLanguageModel = readonly specificationVersion: 'v3'; readonly provider: string; readonly modelId: string; - /** - * Stream method compatible with both V2 and V3 models. - * V3 models accept V2-style call options due to structural compatibility - * at runtime - the prompt and options structures are essentially identical. - */ - doStream(options: LanguageModelV2CallOptions): PromiseLike<{ - stream: ReadableStream; - }>; }; + +// --------------------------------------------------------------------------- +// V3 stream part extensions +// +// These interfaces centralize duck-typing assumptions for AI SDK v6 +// properties that don't exist in the installed @ai-sdk/provider V2 type +// definitions. When @ai-sdk/provider ships V3 types, replace these with +// the canonical imports and remove the casts in do-stream-step.ts. +// --------------------------------------------------------------------------- + +/** @internal V3 adds a `preliminary` flag to tool-result stream parts. */ +export interface V3ToolResultExtension { + preliminary?: boolean; +} + +/** @internal V3 adds `title` and `dynamic` to tool-input-start stream parts. */ +export interface V3ToolInputStartExtension { + title?: string; + dynamic?: boolean; +} + +/** @internal V3 adds a `dynamic` flag to tool calls. */ +export interface V3ToolCallExtension { + dynamic?: boolean; +} + +/** @internal V3 doStream result includes request metadata for telemetry. */ +export interface V3DoStreamRequestMetadata { + body?: unknown; +}