From a347a7031c1a27559d461cfd042e1630f5328e07 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Tue, 31 Mar 2026 15:30:02 +0200 Subject: [PATCH 01/30] feat(ai): add @ag-ui/core as dependency for spec-compliant event types --- packages/typescript/ai/package.json | 1 + pnpm-lock.yaml | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/packages/typescript/ai/package.json b/packages/typescript/ai/package.json index b5c258e4..417cb639 100644 --- a/packages/typescript/ai/package.json +++ b/packages/typescript/ai/package.json @@ -55,6 +55,7 @@ "embeddings" ], "dependencies": { + "@ag-ui/core": "0.0.48", "@tanstack/ai-event-client": "workspace:*", "partial-json": "^0.1.7" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ce14052..f9ca45f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -807,6 +807,9 @@ importers: packages/typescript/ai: dependencies: + '@ag-ui/core': + specifier: 0.0.48 + version: 0.0.48 '@tanstack/ai-event-client': specifier: workspace:* version: link:../ai-event-client @@ -1767,6 +1770,9 @@ packages: '@acemir/cssom@0.9.29': resolution: {integrity: sha512-G90x0VW+9nW4dFajtjCoT+NM0scAfH9Mb08IcjgFHYbfiL/lU04dTF9JuVOi3/OH+DJCQdcIseSXkdCB9Ky6JA==} + '@ag-ui/core@0.0.48': + resolution: {integrity: sha512-HP4wO+0+j8Rkshn0eiV0XAfrh7zeTbvZsQ2URopmMXVK3f6DC2I3jw3IN1ZGaJwSuzPtch5T3NC4jrj/PazVeA==} + '@alcyone-labs/zod-to-json-schema@4.0.10': resolution: {integrity: sha512-TFsSpAPToqmqmT85SGHXuxoCwEeK9zUDvn512O9aBVvWRhSuy+VvAXZkifzsdllD3ncF0ZjUrf4MpBwIEixdWQ==} peerDependencies: @@ -9043,6 +9049,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + rxjs@7.8.1: + resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} + rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} @@ -10609,6 +10618,11 @@ snapshots: '@acemir/cssom@0.9.29': {} + '@ag-ui/core@0.0.48': + dependencies: + rxjs: 7.8.1 + zod: 3.25.76 + '@alcyone-labs/zod-to-json-schema@4.0.10(zod@4.2.1)': dependencies: zod: 4.2.1 @@ -19505,6 +19519,10 @@ snapshots: dependencies: queue-microtask: 1.2.3 + rxjs@7.8.1: + dependencies: + tslib: 2.8.1 + rxjs@7.8.2: dependencies: tslib: 2.8.1 From f964462d8769234775d5cb507d2715de62077bc1 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Tue, 31 Mar 2026 15:42:41 +0200 Subject: [PATCH 02/30] feat(ai): redefine AG-UI event types extending @ag-ui/core Replace custom AG-UI event types with interfaces that extend @ag-ui/core types for spec compliance. This is the foundational type change for the AG-UI protocol alignment. - Import all event types from @ag-ui/core with AGUI* aliases - Replace BaseAGUIEvent to extend @ag-ui/core BaseEvent - Replace each event interface to extend its @ag-ui/core equivalent - Add TanStack-internal extension fields (model, deprecated aliases) - Add new event types: ToolCallResultEvent, Reasoning* events - Deprecate AGUIEventType in favor of EventType enum - Re-export EventType enum from @ag-ui/core - Add threadId/runId to TextOptions interface - Update AGUIEvent union and StreamChunk type alias --- packages/typescript/ai/src/types.ts | 380 ++++++++++++++++++++-------- 1 file changed, 271 insertions(+), 109 deletions(-) diff --git a/packages/typescript/ai/src/types.ts b/packages/typescript/ai/src/types.ts index 984e1512..172524ae 100644 --- a/packages/typescript/ai/src/types.ts +++ b/packages/typescript/ai/src/types.ts @@ -1,4 +1,30 @@ import type { StandardJSONSchemaV1 } from '@standard-schema/spec' +import type { + EventType, + BaseEvent as AGUIBaseEvent, + RunStartedEvent as AGUIRunStartedEvent, + RunFinishedEvent as AGUIRunFinishedEvent, + RunErrorEvent as AGUIRunErrorEvent, + TextMessageStartEvent as AGUITextMessageStartEvent, + TextMessageContentEvent as AGUITextMessageContentEvent, + TextMessageEndEvent as AGUITextMessageEndEvent, + ToolCallStartEvent as AGUIToolCallStartEvent, + ToolCallArgsEvent as AGUIToolCallArgsEvent, + ToolCallEndEvent as AGUIToolCallEndEvent, + ToolCallResultEvent as AGUIToolCallResultEvent, + StepStartedEvent as AGUIStepStartedEvent, + StepFinishedEvent as AGUIStepFinishedEvent, + MessagesSnapshotEvent as AGUIMessagesSnapshotEvent, + StateSnapshotEvent as AGUIStateSnapshotEvent, + StateDeltaEvent as AGUIStateDeltaEvent, + CustomEvent as AGUICustomEvent, + ReasoningStartEvent as AGUIReasoningStartEvent, + ReasoningMessageStartEvent as AGUIReasoningMessageStartEvent, + ReasoningMessageContentEvent as AGUIReasoningMessageContentEvent, + ReasoningMessageEndEvent as AGUIReasoningMessageEndEvent, + ReasoningEndEvent as AGUIReasoningEndEvent, + ReasoningEncryptedValueEvent as AGUIReasoningEncryptedValueEvent, +} from '@ag-ui/core' /** * Tool call states - track the lifecycle of a tool call @@ -712,50 +738,53 @@ export interface TextOptions< * @see https://developer.mozilla.org/en-US/docs/Web/API/AbortController */ abortController?: AbortController + /** + * Thread ID for AG-UI protocol run correlation. + * When provided, this will be used in RunStartedEvent and RunFinishedEvent. + */ + threadId?: string + /** + * Run ID for AG-UI protocol run correlation. + * When provided, this will be used in RunStartedEvent and RunFinishedEvent. + * If not provided, a unique ID will be generated. + */ + runId?: string } // ============================================================================ // AG-UI Protocol Event Types // ============================================================================ +/** + * Re-export EventType enum from @ag-ui/core for use in event creation. + * Use `EventType.RUN_STARTED` etc. when constructing event objects. + */ +export { EventType } from '@ag-ui/core' + /** * AG-UI Protocol event types. - * Based on the AG-UI specification for agent-user interaction. + * @deprecated Use `EventType` enum from `@ag-ui/core` instead. This type alias + * is kept for backward compatibility but will be removed in a future version. * @see https://docs.ag-ui.com/concepts/events */ -export type AGUIEventType = - | 'RUN_STARTED' - | 'RUN_FINISHED' - | 'RUN_ERROR' - | 'TEXT_MESSAGE_START' - | 'TEXT_MESSAGE_CONTENT' - | 'TEXT_MESSAGE_END' - | 'TOOL_CALL_START' - | 'TOOL_CALL_ARGS' - | 'TOOL_CALL_END' - | 'STEP_STARTED' - | 'STEP_FINISHED' - | 'MESSAGES_SNAPSHOT' - | 'STATE_SNAPSHOT' - | 'STATE_DELTA' - | 'CUSTOM' +export type AGUIEventType = `${EventType}` /** * Stream chunk/event types (AG-UI protocol). + * @deprecated Use `EventType` enum instead. */ export type StreamChunkType = AGUIEventType /** * Base structure for AG-UI events. - * Extends AG-UI spec with TanStack AI additions (model field). + * Extends @ag-ui/core BaseEvent with TanStack AI additions. + * + * @ag-ui/core provides: `type`, `timestamp?`, `rawEvent?` + * TanStack AI adds: `model?` */ -export interface BaseAGUIEvent { - type: AGUIEventType - timestamp: number +export interface BaseAGUIEvent extends AGUIBaseEvent { /** Model identifier for multi-model support */ model?: string - /** Original provider event for debugging/advanced use cases */ - rawEvent?: unknown } // ============================================================================ @@ -765,24 +794,26 @@ export interface BaseAGUIEvent { /** * Emitted when a run starts. * This is the first event in any streaming response. + * + * @ag-ui/core provides: `threadId`, `runId`, `parentRunId?`, `input?` + * TanStack AI adds: `model?` */ -export interface RunStartedEvent extends BaseAGUIEvent { - type: 'RUN_STARTED' - /** Unique identifier for this run */ - runId: string - /** Optional thread/conversation ID */ - threadId?: string +export interface RunStartedEvent extends AGUIRunStartedEvent { + /** Model identifier for multi-model support */ + model?: string } /** * Emitted when a run completes successfully. + * + * @ag-ui/core provides: `threadId`, `runId`, `result?` + * TanStack AI adds: `model?`, `finishReason?`, `usage?` */ -export interface RunFinishedEvent extends BaseAGUIEvent { - type: 'RUN_FINISHED' - /** Run identifier */ - runId: string +export interface RunFinishedEvent extends AGUIRunFinishedEvent { + /** Model identifier for multi-model support */ + model?: string /** Why the generation stopped */ - finishReason: 'stop' | 'length' | 'content_filter' | 'tool_calls' | null + finishReason?: 'stop' | 'length' | 'content_filter' | 'tool_calls' | null /** Token usage statistics */ usage?: { promptTokens: number @@ -793,13 +824,18 @@ export interface RunFinishedEvent extends BaseAGUIEvent { /** * Emitted when an error occurs during a run. + * + * @ag-ui/core provides: `message`, `code?` + * TanStack AI adds: `model?`, `error?` (deprecated nested form) */ -export interface RunErrorEvent extends BaseAGUIEvent { - type: 'RUN_ERROR' - /** Run identifier (if available) */ - runId?: string - /** Error details */ - error: { +export interface RunErrorEvent extends AGUIRunErrorEvent { + /** Model identifier for multi-model support */ + model?: string + /** + * @deprecated Use top-level `message` and `code` fields instead. + * Kept for backward compatibility. + */ + error?: { message: string code?: string } @@ -807,48 +843,53 @@ export interface RunErrorEvent extends BaseAGUIEvent { /** * Emitted when a text message starts. + * + * @ag-ui/core provides: `messageId`, `role?`, `name?` + * TanStack AI adds: `model?` */ -export interface TextMessageStartEvent extends BaseAGUIEvent { - type: 'TEXT_MESSAGE_START' - /** Unique identifier for this message */ - messageId: string - /** Role of the message sender */ - role: 'user' | 'assistant' | 'system' | 'tool' +export interface TextMessageStartEvent extends AGUITextMessageStartEvent { + /** Model identifier for multi-model support */ + model?: string } /** * Emitted when text content is generated (streaming tokens). + * + * @ag-ui/core provides: `messageId`, `delta` + * TanStack AI adds: `model?`, `content?` (accumulated) */ -export interface TextMessageContentEvent extends BaseAGUIEvent { - type: 'TEXT_MESSAGE_CONTENT' - /** Message identifier */ - messageId: string - /** The incremental content token */ - delta: string - /** Full accumulated content so far (optional, for debugging) */ +export interface TextMessageContentEvent extends AGUITextMessageContentEvent { + /** Model identifier for multi-model support */ + model?: string + /** Full accumulated content so far (TanStack AI internal, for debugging) */ content?: string } /** * Emitted when a text message completes. + * + * @ag-ui/core provides: `messageId` + * TanStack AI adds: `model?` */ -export interface TextMessageEndEvent extends BaseAGUIEvent { - type: 'TEXT_MESSAGE_END' - /** Message identifier */ - messageId: string +export interface TextMessageEndEvent extends AGUITextMessageEndEvent { + /** Model identifier for multi-model support */ + model?: string } /** * Emitted when a tool call starts. + * + * @ag-ui/core provides: `toolCallId`, `toolCallName`, `parentMessageId?` + * TanStack AI adds: `model?`, `toolName` (deprecated alias), `index?`, `providerMetadata?` */ -export interface ToolCallStartEvent extends BaseAGUIEvent { - type: 'TOOL_CALL_START' - /** Unique identifier for this tool call */ - toolCallId: string - /** Name of the tool being called */ +export interface ToolCallStartEvent extends AGUIToolCallStartEvent { + /** Model identifier for multi-model support */ + model?: string + /** + * @deprecated Use `toolCallName` instead (from @ag-ui/core spec). + * Kept for backward compatibility. + */ toolName: string - /** ID of the parent message that initiated this tool call */ - parentMessageId?: string /** Index for parallel tool calls */ index?: number /** Provider-specific metadata to carry into the ToolCall */ @@ -857,53 +898,80 @@ export interface ToolCallStartEvent extends BaseAGUIEvent { /** * Emitted when tool call arguments are streaming. + * + * @ag-ui/core provides: `toolCallId`, `delta` + * TanStack AI adds: `model?`, `args?` (accumulated) */ -export interface ToolCallArgsEvent extends BaseAGUIEvent { - type: 'TOOL_CALL_ARGS' - /** Tool call identifier */ - toolCallId: string - /** Incremental JSON arguments delta */ - delta: string - /** Full accumulated arguments so far */ +export interface ToolCallArgsEvent extends AGUIToolCallArgsEvent { + /** Model identifier for multi-model support */ + model?: string + /** Full accumulated arguments so far (TanStack AI internal) */ args?: string } /** * Emitted when a tool call completes. + * + * @ag-ui/core provides: `toolCallId` + * TanStack AI adds: `model?`, `toolName?`, `input?`, `result?` */ -export interface ToolCallEndEvent extends BaseAGUIEvent { - type: 'TOOL_CALL_END' - /** Tool call identifier */ - toolCallId: string - /** Name of the tool */ - toolName: string - /** Final parsed input arguments */ +export interface ToolCallEndEvent extends AGUIToolCallEndEvent { + /** Model identifier for multi-model support */ + model?: string + /** Name of the tool (TanStack AI internal) */ + toolName?: string + /** Final parsed input arguments (TanStack AI internal) */ input?: unknown - /** Tool execution result (if executed) */ + /** Tool execution result (TanStack AI internal) */ result?: string } +/** + * Emitted when a tool call result is available. + * + * @ag-ui/core provides: `messageId`, `toolCallId`, `content`, `role?` + * TanStack AI adds: `model?` + */ +export interface ToolCallResultEvent extends AGUIToolCallResultEvent { + /** Model identifier for multi-model support */ + model?: string +} + /** * Emitted when a thinking/reasoning step starts. + * + * @ag-ui/core provides: `stepName` + * TanStack AI adds: `model?`, `stepId?` (deprecated alias), `stepType?` */ -export interface StepStartedEvent extends BaseAGUIEvent { - type: 'STEP_STARTED' - /** Unique identifier for this step */ - stepId: string +export interface StepStartedEvent extends AGUIStepStartedEvent { + /** Model identifier for multi-model support */ + model?: string + /** + * @deprecated Use `stepName` instead (from @ag-ui/core spec). + * Kept for backward compatibility. + */ + stepId?: string /** Type of step (e.g., 'thinking', 'planning') */ stepType?: string } /** * Emitted when a thinking/reasoning step finishes. + * + * @ag-ui/core provides: `stepName` + * TanStack AI adds: `model?`, `stepId?` (deprecated alias), `delta?`, `content?` */ -export interface StepFinishedEvent extends BaseAGUIEvent { - type: 'STEP_FINISHED' - /** Step identifier */ - stepId: string - /** Incremental thinking content */ - delta: string - /** Full accumulated thinking content (optional, for debugging) */ +export interface StepFinishedEvent extends AGUIStepFinishedEvent { + /** Model identifier for multi-model support */ + model?: string + /** + * @deprecated Use `stepName` instead (from @ag-ui/core spec). + * Kept for backward compatibility. + */ + stepId?: string + /** Incremental thinking content (TanStack AI internal) */ + delta?: string + /** Full accumulated thinking content (TanStack AI internal) */ content?: string } @@ -912,43 +980,130 @@ export interface StepFinishedEvent extends BaseAGUIEvent { * * Unlike StateSnapshot (which carries arbitrary application state), * MessagesSnapshot specifically delivers the conversation transcript. - * This is a first-class AG-UI event type. + * + * @ag-ui/core provides: `messages` (as @ag-ui/core Message[]) + * TanStack AI adds: `model?` + * + * Note: The `messages` field uses the @ag-ui/core Message type. + * Use converters to transform to/from TanStack UIMessage format. */ -export interface MessagesSnapshotEvent extends BaseAGUIEvent { - type: 'MESSAGES_SNAPSHOT' - /** Complete array of messages in the conversation */ - messages: Array +export interface MessagesSnapshotEvent extends AGUIMessagesSnapshotEvent { + /** Model identifier for multi-model support */ + model?: string } /** * Emitted to provide a full state snapshot. + * + * @ag-ui/core provides: `snapshot` (any) + * TanStack AI adds: `model?`, `state?` (deprecated alias for snapshot) */ -export interface StateSnapshotEvent extends BaseAGUIEvent { - type: 'STATE_SNAPSHOT' - /** The complete state object */ - state: Record +export interface StateSnapshotEvent extends AGUIStateSnapshotEvent { + /** Model identifier for multi-model support */ + model?: string + /** + * @deprecated Use `snapshot` instead (from @ag-ui/core spec). + * Kept for backward compatibility. + */ + state?: Record } /** * Emitted to provide an incremental state update. + * + * @ag-ui/core provides: `delta` (any[] - JSON Patch RFC 6902) + * TanStack AI adds: `model?` */ -export interface StateDeltaEvent extends BaseAGUIEvent { - type: 'STATE_DELTA' - /** The state changes to apply */ - delta: Record +export interface StateDeltaEvent extends AGUIStateDeltaEvent { + /** Model identifier for multi-model support */ + model?: string } /** * Custom event for extensibility. + * + * @ag-ui/core provides: `name`, `value` + * TanStack AI adds: `model?` */ -export interface CustomEvent extends BaseAGUIEvent { - type: 'CUSTOM' - /** Custom event name */ - name: string - /** Custom event value */ - value?: unknown +export interface CustomEvent extends AGUICustomEvent { + /** Model identifier for multi-model support */ + model?: string +} + +// ============================================================================ +// AG-UI Reasoning Event Interfaces +// ============================================================================ + +/** + * Emitted when reasoning starts for a message. + * + * @ag-ui/core provides: `messageId` + * TanStack AI adds: `model?` + */ +export interface ReasoningStartEvent extends AGUIReasoningStartEvent { + /** Model identifier for multi-model support */ + model?: string +} + +/** + * Emitted when a reasoning message starts. + * + * @ag-ui/core provides: `messageId`, `role` ("reasoning") + * TanStack AI adds: `model?` + */ +export interface ReasoningMessageStartEvent extends AGUIReasoningMessageStartEvent { + /** Model identifier for multi-model support */ + model?: string +} + +/** + * Emitted when reasoning message content is generated. + * + * @ag-ui/core provides: `messageId`, `delta` + * TanStack AI adds: `model?` + */ +export interface ReasoningMessageContentEvent extends AGUIReasoningMessageContentEvent { + /** Model identifier for multi-model support */ + model?: string +} + +/** + * Emitted when a reasoning message ends. + * + * @ag-ui/core provides: `messageId` + * TanStack AI adds: `model?` + */ +export interface ReasoningMessageEndEvent extends AGUIReasoningMessageEndEvent { + /** Model identifier for multi-model support */ + model?: string +} + +/** + * Emitted when reasoning ends for a message. + * + * @ag-ui/core provides: `messageId` + * TanStack AI adds: `model?` + */ +export interface ReasoningEndEvent extends AGUIReasoningEndEvent { + /** Model identifier for multi-model support */ + model?: string +} + +/** + * Emitted for encrypted reasoning values. + * + * @ag-ui/core provides: `subtype`, `entityId`, `encryptedValue` + * TanStack AI adds: `model?` + */ +export interface ReasoningEncryptedValueEvent extends AGUIReasoningEncryptedValueEvent { + /** Model identifier for multi-model support */ + model?: string } +// ============================================================================ +// AG-UI Event Union +// ============================================================================ + /** * Union of all AG-UI events. */ @@ -962,12 +1117,19 @@ export type AGUIEvent = | ToolCallStartEvent | ToolCallArgsEvent | ToolCallEndEvent + | ToolCallResultEvent | StepStartedEvent | StepFinishedEvent | MessagesSnapshotEvent | StateSnapshotEvent | StateDeltaEvent | CustomEvent + | ReasoningStartEvent + | ReasoningMessageStartEvent + | ReasoningMessageContentEvent + | ReasoningMessageEndEvent + | ReasoningEndEvent + | ReasoningEncryptedValueEvent /** * Chunk returned by the SDK during streaming chat completions. From 3aa72174aa2d09abe94a1a0ef46752ac69e7a87d Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Tue, 31 Mar 2026 15:48:15 +0200 Subject: [PATCH 03/30] feat(ai): add stripToSpec middleware to strip non-spec fields from stream events Creates a middleware that removes TanStack-internal extension fields (model, rawEvent, deprecated aliases) from StreamChunk events so the yielded stream is @ag-ui/core spec-compliant. Registered as the last middleware in the chat activity chain so devtools and user middleware still see the full extended events. --- .../ai/src/activities/chat/index.ts | 3 +- .../ai/src/strip-to-spec-middleware.ts | 52 ++++ .../ai/tests/strip-to-spec-middleware.test.ts | 245 ++++++++++++++++++ 3 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 packages/typescript/ai/src/strip-to-spec-middleware.ts create mode 100644 packages/typescript/ai/tests/strip-to-spec-middleware.test.ts diff --git a/packages/typescript/ai/src/activities/chat/index.ts b/packages/typescript/ai/src/activities/chat/index.ts index c7ec866d..e11f7917 100644 --- a/packages/typescript/ai/src/activities/chat/index.ts +++ b/packages/typescript/ai/src/activities/chat/index.ts @@ -6,6 +6,7 @@ */ import { devtoolsMiddleware } from '@tanstack/ai-event-client' +import { stripToSpecMiddleware } from '../../strip-to-spec-middleware' import { streamToText } from '../../stream-to-response.js' import { LazyToolManager } from './tools/lazy-tool-manager' import { @@ -309,7 +310,7 @@ class TextEngine< this.effectiveSignal = config.params.abortController?.signal // Initialize middleware — devtools middleware is always first - const allMiddleware = [devtoolsMiddleware(), ...(config.middleware || [])] + const allMiddleware = [devtoolsMiddleware(), ...(config.middleware || []), stripToSpecMiddleware()] this.middlewareRunner = new MiddlewareRunner(allMiddleware) this.middlewareAbortController = new AbortController() this.middlewareCtx = { diff --git a/packages/typescript/ai/src/strip-to-spec-middleware.ts b/packages/typescript/ai/src/strip-to-spec-middleware.ts new file mode 100644 index 00000000..741c6ca0 --- /dev/null +++ b/packages/typescript/ai/src/strip-to-spec-middleware.ts @@ -0,0 +1,52 @@ +import type { ChatMiddleware } from './activities/chat/middleware/types' +import type { StreamChunk } from './types' + +/** + * Set of fields to always strip from events (TanStack extensions not in @ag-ui/core spec). + */ +const ALWAYS_STRIP = new Set(['model', 'rawEvent']) + +/** + * Per-event-type fields to strip. These are TanStack-internal extension fields + * and deprecated aliases that should not appear on the wire. + */ +const STRIP_BY_TYPE: Record> = { + TEXT_MESSAGE_CONTENT: new Set(['content']), + TOOL_CALL_START: new Set(['toolName', 'index', 'providerMetadata']), + TOOL_CALL_ARGS: new Set(['args']), + TOOL_CALL_END: new Set(['toolName', 'input', 'result']), + RUN_FINISHED: new Set(['finishReason', 'usage']), + RUN_ERROR: new Set(['error']), + STEP_STARTED: new Set(['stepId', 'stepType']), + STEP_FINISHED: new Set(['stepId', 'delta', 'content']), + STATE_SNAPSHOT: new Set(['state']), +} + +/** + * Strip non-spec fields from a StreamChunk, producing an @ag-ui/core spec-compliant event. + */ +export function stripToSpec(chunk: StreamChunk): StreamChunk { + const typeStrip = STRIP_BY_TYPE[chunk.type] + const result: Record = {} + + for (const [key, value] of Object.entries(chunk)) { + if (ALWAYS_STRIP.has(key)) continue + if (typeStrip?.has(key)) continue + result[key] = value + } + + return result as StreamChunk +} + +/** + * Middleware that strips non-spec fields from events. + * Should always be the LAST middleware in the chain. + */ +export function stripToSpecMiddleware(): ChatMiddleware { + return { + name: 'strip-to-spec', + onChunk(_ctx, chunk) { + return stripToSpec(chunk) + }, + } +} diff --git a/packages/typescript/ai/tests/strip-to-spec-middleware.test.ts b/packages/typescript/ai/tests/strip-to-spec-middleware.test.ts new file mode 100644 index 00000000..8397d60c --- /dev/null +++ b/packages/typescript/ai/tests/strip-to-spec-middleware.test.ts @@ -0,0 +1,245 @@ +import { describe, it, expect } from 'vitest' +import { stripToSpec } from '../src/strip-to-spec-middleware' +import type { StreamChunk } from '../src/types' + +/** + * Helper to create a StreamChunk with the given type and fields. + */ +function makeChunk( + type: StreamChunk['type'], + fields: Record, +): StreamChunk { + return { type, timestamp: Date.now(), ...fields } as unknown as StreamChunk +} + +describe('stripToSpec', () => { + it('removes `model` from all event types', () => { + const chunk = makeChunk('RUN_STARTED', { + runId: 'run-1', + model: 'gpt-4o', + }) + const result = stripToSpec(chunk) as Record + expect(result).not.toHaveProperty('model') + expect(result).toHaveProperty('runId', 'run-1') + expect(result).toHaveProperty('type', 'RUN_STARTED') + }) + + it('removes `rawEvent` from all event types', () => { + const chunk = makeChunk('TEXT_MESSAGE_START', { + messageId: 'msg-1', + role: 'assistant', + rawEvent: { some: 'raw data' }, + model: 'gpt-4o', + }) + const result = stripToSpec(chunk) as Record + expect(result).not.toHaveProperty('rawEvent') + expect(result).not.toHaveProperty('model') + expect(result).toHaveProperty('messageId', 'msg-1') + }) + + it('removes accumulated `content` from TEXT_MESSAGE_CONTENT, keeps `delta`', () => { + const chunk = makeChunk('TEXT_MESSAGE_CONTENT', { + messageId: 'msg-1', + delta: 'Hello', + content: 'Hello World', + model: 'gpt-4o', + }) + const result = stripToSpec(chunk) as Record + expect(result).not.toHaveProperty('content') + expect(result).not.toHaveProperty('model') + expect(result).toHaveProperty('delta', 'Hello') + expect(result).toHaveProperty('messageId', 'msg-1') + expect(result).toHaveProperty('type', 'TEXT_MESSAGE_CONTENT') + }) + + it('removes `toolName`, `index`, `providerMetadata` from TOOL_CALL_START, keeps `toolCallName`', () => { + const chunk = makeChunk('TOOL_CALL_START', { + toolCallId: 'tc-1', + toolCallName: 'getTodos', + toolName: 'getTodos', + index: 0, + providerMetadata: { foo: 'bar' }, + model: 'gpt-4o', + }) + const result = stripToSpec(chunk) as Record + expect(result).not.toHaveProperty('toolName') + expect(result).not.toHaveProperty('index') + expect(result).not.toHaveProperty('providerMetadata') + expect(result).not.toHaveProperty('model') + expect(result).toHaveProperty('toolCallId', 'tc-1') + expect(result).toHaveProperty('toolCallName', 'getTodos') + expect(result).toHaveProperty('type', 'TOOL_CALL_START') + }) + + it('removes `args` from TOOL_CALL_ARGS, keeps `delta`', () => { + const chunk = makeChunk('TOOL_CALL_ARGS', { + toolCallId: 'tc-1', + delta: '{"userId":', + args: '{"userId":', + model: 'gpt-4o', + }) + const result = stripToSpec(chunk) as Record + expect(result).not.toHaveProperty('args') + expect(result).not.toHaveProperty('model') + expect(result).toHaveProperty('delta', '{"userId":') + expect(result).toHaveProperty('toolCallId', 'tc-1') + expect(result).toHaveProperty('type', 'TOOL_CALL_ARGS') + }) + + it('strips TOOL_CALL_END to only `toolCallId` + base fields', () => { + const chunk = makeChunk('TOOL_CALL_END', { + toolCallId: 'tc-1', + toolName: 'getTodos', + input: { userId: '123' }, + result: '[{"id":"1","title":"Buy milk"}]', + model: 'gpt-4o', + }) + const result = stripToSpec(chunk) as Record + expect(result).not.toHaveProperty('toolName') + expect(result).not.toHaveProperty('input') + expect(result).not.toHaveProperty('result') + expect(result).not.toHaveProperty('model') + expect(result).toHaveProperty('toolCallId', 'tc-1') + expect(result).toHaveProperty('type', 'TOOL_CALL_END') + expect(result).toHaveProperty('timestamp') + }) + + it('strips RUN_STARTED to spec fields (threadId, runId, type, timestamp)', () => { + const chunk = makeChunk('RUN_STARTED', { + runId: 'run-1', + threadId: 'thread-1', + model: 'gpt-4o', + }) + const result = stripToSpec(chunk) as Record + expect(result).not.toHaveProperty('model') + expect(result).toHaveProperty('type', 'RUN_STARTED') + expect(result).toHaveProperty('runId', 'run-1') + expect(result).toHaveProperty('threadId', 'thread-1') + expect(result).toHaveProperty('timestamp') + }) + + it('strips RUN_FINISHED to spec fields (removes finishReason, usage)', () => { + const chunk = makeChunk('RUN_FINISHED', { + runId: 'run-1', + model: 'gpt-4o', + finishReason: 'stop', + usage: { + promptTokens: 100, + completionTokens: 50, + totalTokens: 150, + }, + }) + const result = stripToSpec(chunk) as Record + expect(result).not.toHaveProperty('model') + expect(result).not.toHaveProperty('finishReason') + expect(result).not.toHaveProperty('usage') + expect(result).toHaveProperty('type', 'RUN_FINISHED') + expect(result).toHaveProperty('runId', 'run-1') + expect(result).toHaveProperty('timestamp') + }) + + it('strips RUN_ERROR deprecated `error` object, keeps flat `message`/`code`', () => { + const chunk = makeChunk('RUN_ERROR', { + runId: 'run-1', + message: 'Something went wrong', + code: 'INTERNAL_ERROR', + error: { message: 'Something went wrong' }, + model: 'gpt-4o', + }) + const result = stripToSpec(chunk) as Record + expect(result).not.toHaveProperty('error') + expect(result).not.toHaveProperty('model') + expect(result).toHaveProperty('message', 'Something went wrong') + expect(result).toHaveProperty('code', 'INTERNAL_ERROR') + expect(result).toHaveProperty('type', 'RUN_ERROR') + }) + + it('strips STEP_STARTED to spec fields (removes stepId, stepType, keeps stepName)', () => { + const chunk = makeChunk('STEP_STARTED', { + stepName: 'thinking', + stepId: 'step-1', + stepType: 'thinking', + model: 'gpt-4o', + }) + const result = stripToSpec(chunk) as Record + expect(result).not.toHaveProperty('stepId') + expect(result).not.toHaveProperty('stepType') + expect(result).not.toHaveProperty('model') + expect(result).toHaveProperty('stepName', 'thinking') + expect(result).toHaveProperty('type', 'STEP_STARTED') + }) + + it('strips STEP_FINISHED to spec fields (removes stepId, delta, content, keeps stepName)', () => { + const chunk = makeChunk('STEP_FINISHED', { + stepName: 'thinking', + stepId: 'step-1', + delta: 'some thinking', + content: 'accumulated thinking', + model: 'gpt-4o', + }) + const result = stripToSpec(chunk) as Record + expect(result).not.toHaveProperty('stepId') + expect(result).not.toHaveProperty('delta') + expect(result).not.toHaveProperty('content') + expect(result).not.toHaveProperty('model') + expect(result).toHaveProperty('stepName', 'thinking') + expect(result).toHaveProperty('type', 'STEP_FINISHED') + }) + + it('strips STATE_SNAPSHOT deprecated `state`, keeps `snapshot`', () => { + const chunk = makeChunk('STATE_SNAPSHOT', { + snapshot: { count: 42 }, + state: { count: 42 }, + model: 'gpt-4o', + }) + const result = stripToSpec(chunk) as Record + expect(result).not.toHaveProperty('state') + expect(result).not.toHaveProperty('model') + expect(result).toHaveProperty('snapshot') + expect((result.snapshot as Record).count).toBe(42) + expect(result).toHaveProperty('type', 'STATE_SNAPSHOT') + }) + + it('passes through REASONING events (only strips model)', () => { + const chunk = makeChunk('REASONING_MESSAGE_CONTENT', { + messageId: 'msg-1', + delta: 'Let me think...', + model: 'gpt-4o', + }) + const result = stripToSpec(chunk) as Record + expect(result).not.toHaveProperty('model') + expect(result).toHaveProperty('delta', 'Let me think...') + expect(result).toHaveProperty('messageId', 'msg-1') + expect(result).toHaveProperty('type', 'REASONING_MESSAGE_CONTENT') + }) + + it('passes through TOOL_CALL_RESULT (only strips model)', () => { + const chunk = makeChunk('TOOL_CALL_RESULT', { + toolCallId: 'tc-1', + messageId: 'msg-result-1', + content: '{"items":[]}', + role: 'tool', + model: 'gpt-4o', + }) + const result = stripToSpec(chunk) as Record + expect(result).not.toHaveProperty('model') + expect(result).toHaveProperty('toolCallId', 'tc-1') + expect(result).toHaveProperty('content', '{"items":[]}') + expect(result).toHaveProperty('type', 'TOOL_CALL_RESULT') + }) + + it('strips `rawEvent` from events along with other type-specific fields', () => { + const chunk = makeChunk('RUN_FINISHED', { + runId: 'run-1', + rawEvent: { originalPayload: true }, + model: 'gpt-4o', + finishReason: 'stop', + }) + const result = stripToSpec(chunk) as Record + expect(result).not.toHaveProperty('rawEvent') + expect(result).not.toHaveProperty('model') + expect(result).not.toHaveProperty('finishReason') + expect(result).toHaveProperty('type', 'RUN_FINISHED') + expect(result).toHaveProperty('runId', 'run-1') + }) +}) From 06bae70b4a30418630bf888c6adf13190d27a95b Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Tue, 31 Mar 2026 15:50:48 +0200 Subject: [PATCH 04/30] feat(ai): plumb threadId and runId through chat() to adapters Add threadId/runId to TextActivityOptions interface and TextEngine class so they flow from user-facing chat() options through to adapter.chatStream(). ThreadId is auto-generated if not provided. Adapters will consume these in subsequent tasks to include them in RUN_STARTED/RUN_FINISHED events. --- packages/typescript/ai/src/activities/chat/index.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/typescript/ai/src/activities/chat/index.ts b/packages/typescript/ai/src/activities/chat/index.ts index e11f7917..96d0be23 100644 --- a/packages/typescript/ai/src/activities/chat/index.ts +++ b/packages/typescript/ai/src/activities/chat/index.ts @@ -105,6 +105,10 @@ export interface TextActivityOptions< agentLoopStrategy?: TextOptions['agentLoopStrategy'] /** Unique conversation identifier for tracking */ conversationId?: TextOptions['conversationId'] + /** Thread/conversation ID for AG-UI protocol. Auto-generated if not provided. */ + threadId?: TextOptions['threadId'] + /** Run ID override for AG-UI protocol. Auto-generated by adapter if not provided. */ + runId?: TextOptions['runId'] /** * Optional Standard Schema for structured output. * When provided, the activity will: @@ -264,6 +268,10 @@ class TextEngine< private readonly initialApprovals: Map private readonly initialClientToolResults: Map + // AG-UI protocol IDs + private threadId: string + private runIdOverride?: string + // Middleware support private readonly middlewareRunner: MiddlewareRunner private readonly middlewareCtx: ChatMiddlewareContext @@ -308,6 +316,8 @@ class TextEngine< ? { signal: config.params.abortController.signal } : undefined this.effectiveSignal = config.params.abortController?.signal + this.threadId = config.params.threadId || this.createId('thread') + this.runIdOverride = config.params.runId // Initialize middleware — devtools middleware is always first const allMiddleware = [devtoolsMiddleware(), ...(config.middleware || []), stripToSpecMiddleware()] @@ -536,6 +546,8 @@ class TextEngine< request: this.effectiveRequest, modelOptions, systemPrompts: this.systemPrompts, + threadId: this.threadId, + runId: this.runIdOverride, })) { if (this.isCancelled()) { break From 7af0890b8142150415536bc653efe7441010dca5 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Tue, 31 Mar 2026 15:57:57 +0200 Subject: [PATCH 05/30] feat(ai-openai): update text adapter for AG-UI spec compliance Add threadId to RUN_STARTED/RUN_FINISHED events, toolCallName to TOOL_CALL_START/TOOL_CALL_END, stepName to STEP_STARTED/STEP_FINISHED, flatten RUN_ERROR with top-level message/code fields, and emit REASONING_START/MESSAGE_START/CONTENT/MESSAGE_END/END events alongside legacy STEP events for reasoning content. --- .../typescript/ai-openai/src/adapters/text.ts | 165 +++++++++++++++++- 1 file changed, 160 insertions(+), 5 deletions(-) diff --git a/packages/typescript/ai-openai/src/adapters/text.ts b/packages/typescript/ai-openai/src/adapters/text.ts index 1747ce4e..44d554b4 100644 --- a/packages/typescript/ai-openai/src/adapters/text.ts +++ b/packages/typescript/ai-openai/src/adapters/text.ts @@ -258,8 +258,11 @@ export class OpenAITextAdapter< // AG-UI lifecycle tracking const runId = genId() + const threadId = options.threadId || genId() const messageId = genId() let stepId: string | null = null + let reasoningMessageId: string | null = null + let hasClosedReasoning = false let hasEmittedRunStarted = false let hasEmittedTextMessageStart = false let hasEmittedStepStarted = false @@ -274,6 +277,7 @@ export class OpenAITextAdapter< yield { type: 'RUN_STARTED', runId, + threadId, model: model || options.model, timestamp, } @@ -299,9 +303,11 @@ export class OpenAITextAdapter< if (contentPart.type === 'reasoning_text') { accumulatedReasoning += contentPart.text + const currentStepId = stepId || genId() return { type: 'STEP_FINISHED', - stepId: stepId || genId(), + stepName: currentStepId, + stepId: currentStepId, model: model || options.model, timestamp, delta: contentPart.text, @@ -311,6 +317,7 @@ export class OpenAITextAdapter< return { type: 'RUN_ERROR', runId, + message: contentPart.refusal, model: model || options.model, timestamp, error: { @@ -330,25 +337,32 @@ export class OpenAITextAdapter< hasStreamedReasoningDeltas = false hasEmittedTextMessageStart = false hasEmittedStepStarted = false + reasoningMessageId = null + hasClosedReasoning = false accumulatedContent = '' accumulatedReasoning = '' if (chunk.response.error) { yield { type: 'RUN_ERROR', runId, + message: chunk.response.error.message, + code: chunk.response.error.code ?? undefined, model: chunk.response.model, timestamp, error: chunk.response.error, } } if (chunk.response.incomplete_details) { + const incompleteMessage = + chunk.response.incomplete_details.reason ?? '' yield { type: 'RUN_ERROR', runId, + message: incompleteMessage, model: chunk.response.model, timestamp, error: { - message: chunk.response.incomplete_details.reason ?? '', + message: incompleteMessage, }, } } @@ -364,6 +378,23 @@ export class OpenAITextAdapter< : '' if (textDelta) { + // Close reasoning events before text starts + if (reasoningMessageId && !hasClosedReasoning) { + hasClosedReasoning = true + yield { + type: 'REASONING_MESSAGE_END', + messageId: reasoningMessageId, + model: model || options.model, + timestamp, + } + yield { + type: 'REASONING_END', + messageId: reasoningMessageId, + model: model || options.model, + timestamp, + } + } + // Emit TEXT_MESSAGE_START on first text content if (!hasEmittedTextMessageStart) { hasEmittedTextMessageStart = true @@ -400,12 +431,31 @@ export class OpenAITextAdapter< : '' if (reasoningDelta) { - // Emit STEP_STARTED on first reasoning content + // Emit STEP_STARTED and REASONING_START on first reasoning content if (!hasEmittedStepStarted) { hasEmittedStepStarted = true stepId = genId() + reasoningMessageId = genId() + + // Spec REASONING events + yield { + type: 'REASONING_START', + messageId: reasoningMessageId, + model: model || options.model, + timestamp, + } + yield { + type: 'REASONING_MESSAGE_START', + messageId: reasoningMessageId, + role: 'reasoning' as const, + model: model || options.model, + timestamp, + } + + // Legacy STEP events (kept during transition) yield { type: 'STEP_STARTED', + stepName: stepId, stepId, model: model || options.model, timestamp, @@ -415,8 +465,20 @@ export class OpenAITextAdapter< accumulatedReasoning += reasoningDelta hasStreamedReasoningDeltas = true + + // Spec REASONING content event + yield { + type: 'REASONING_MESSAGE_CONTENT', + messageId: reasoningMessageId!, + delta: reasoningDelta, + model: model || options.model, + timestamp, + } + + // Legacy STEP event yield { type: 'STEP_FINISHED', + stepName: stepId || genId(), stepId: stepId || genId(), model: model || options.model, timestamp, @@ -436,12 +498,31 @@ export class OpenAITextAdapter< typeof chunk.delta === 'string' ? chunk.delta : '' if (summaryDelta) { - // Emit STEP_STARTED on first reasoning content + // Emit STEP_STARTED and REASONING_START on first reasoning content if (!hasEmittedStepStarted) { hasEmittedStepStarted = true stepId = genId() + reasoningMessageId = genId() + + // Spec REASONING events + yield { + type: 'REASONING_START', + messageId: reasoningMessageId, + model: model || options.model, + timestamp, + } + yield { + type: 'REASONING_MESSAGE_START', + messageId: reasoningMessageId, + role: 'reasoning' as const, + model: model || options.model, + timestamp, + } + + // Legacy STEP events (kept during transition) yield { type: 'STEP_STARTED', + stepName: stepId, stepId, model: model || options.model, timestamp, @@ -451,8 +532,20 @@ export class OpenAITextAdapter< accumulatedReasoning += summaryDelta hasStreamedReasoningDeltas = true + + // Spec REASONING content event + yield { + type: 'REASONING_MESSAGE_CONTENT', + messageId: reasoningMessageId!, + delta: summaryDelta, + model: model || options.model, + timestamp, + } + + // Legacy STEP event yield { type: 'STEP_FINISHED', + stepName: stepId || genId(), stepId: stepId || genId(), model: model || options.model, timestamp, @@ -465,6 +558,25 @@ export class OpenAITextAdapter< // handle content_part added events for text, reasoning and refusals if (chunk.type === 'response.content_part.added') { const contentPart = chunk.part + // Close reasoning before text starts + if (contentPart.type === 'output_text') { + if (reasoningMessageId && !hasClosedReasoning) { + hasClosedReasoning = true + yield { + type: 'REASONING_MESSAGE_END', + messageId: reasoningMessageId, + model: model || options.model, + timestamp, + } + yield { + type: 'REASONING_END', + messageId: reasoningMessageId, + model: model || options.model, + timestamp, + } + } + } + // Emit TEXT_MESSAGE_START if this is text content if ( contentPart.type === 'output_text' && @@ -479,12 +591,31 @@ export class OpenAITextAdapter< role: 'assistant', } } - // Emit STEP_STARTED if this is reasoning content + // Emit STEP_STARTED and REASONING events if this is reasoning content if (contentPart.type === 'reasoning_text' && !hasEmittedStepStarted) { hasEmittedStepStarted = true stepId = genId() + reasoningMessageId = genId() + + // Spec REASONING events + yield { + type: 'REASONING_START', + messageId: reasoningMessageId, + model: model || options.model, + timestamp, + } + yield { + type: 'REASONING_MESSAGE_START', + messageId: reasoningMessageId, + role: 'reasoning' as const, + model: model || options.model, + timestamp, + } + + // Legacy STEP events (kept during transition) yield { type: 'STEP_STARTED', + stepName: stepId, stepId, model: model || options.model, timestamp, @@ -531,6 +662,7 @@ export class OpenAITextAdapter< yield { type: 'TOOL_CALL_START', toolCallId: item.id, + toolCallName: item.name || '', toolName: item.name || '', model: model || options.model, timestamp, @@ -574,6 +706,7 @@ export class OpenAITextAdapter< yield { type: 'TOOL_CALL_END', toolCallId: item_id, + toolCallName: name, toolName: name, model: model || options.model, timestamp, @@ -582,6 +715,23 @@ export class OpenAITextAdapter< } if (chunk.type === 'response.completed') { + // Close reasoning events if still open + if (reasoningMessageId && !hasClosedReasoning) { + hasClosedReasoning = true + yield { + type: 'REASONING_MESSAGE_END', + messageId: reasoningMessageId, + model: model || options.model, + timestamp, + } + yield { + type: 'REASONING_END', + messageId: reasoningMessageId, + model: model || options.model, + timestamp, + } + } + // Emit TEXT_MESSAGE_END if we had text content if (hasEmittedTextMessageStart) { yield { @@ -602,6 +752,7 @@ export class OpenAITextAdapter< yield { type: 'RUN_FINISHED', runId, + threadId, model: model || options.model, timestamp, usage: { @@ -617,6 +768,8 @@ export class OpenAITextAdapter< yield { type: 'RUN_ERROR', runId, + message: chunk.message, + code: chunk.code ?? undefined, model: model || options.model, timestamp, error: { @@ -638,6 +791,8 @@ export class OpenAITextAdapter< yield { type: 'RUN_ERROR', runId, + message: err.message || 'Unknown error occurred', + code: err.code, model: options.model, timestamp, error: { From 1757208a4c143bfab8445d781587fbb076fd2adc Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Tue, 31 Mar 2026 16:46:30 +0200 Subject: [PATCH 06/30] fix: update tests and fix type errors for AG-UI spec compliance Update test utilities and tests to use AG-UI spec field names: - Add threadId to RUN_STARTED/RUN_FINISHED events - Add toolCallName alongside deprecated toolName on tool events - Add stepName alongside deprecated stepId on step events - Use flat message field on RUN_ERROR (with deprecated error nested form) Fix critical bugs discovered during testing: - StreamProcessor: prefer chunk.message over chunk.error?.message for RUN_ERROR - TextEngine: process original chunks for internal state before middleware strips fields - Remove auto-applied stripToSpecMiddleware from chat() (breaks internal state since it strips finishReason, delta, content needed by TextEngine and StreamProcessor) - Fix type compatibility issues with @ag-ui/core EventType enum vs string literals Also fix type errors in: - stream-generation-result.ts: use EventType enum and add threadId - generateVideo/index.ts: add StreamChunk casts and threadId - tool-calls.ts: cast TOOL_CALL_END yield to ToolCallEndEvent - devtools-middleware.ts: handle toolCallName fallback and RUN_ERROR message field - processor.ts: handle developer role, Messages snapshot type cast, finishReason undefined --- .../ai-anthropic/src/adapters/text.ts | 107 +++++++++++++++- .../typescript/ai-client/tests/test-utils.ts | 42 ++++--- .../src/devtools-middleware.ts | 7 +- .../typescript/ai-gemini/src/adapters/text.ts | 88 ++++++++++++- .../typescript/ai-grok/src/adapters/text.ts | 11 ++ .../typescript/ai-groq/src/adapters/text.ts | 11 ++ .../typescript/ai-ollama/src/adapters/text.ts | 77 +++++++++++- .../ai-openai/src/realtime/adapter.ts | 8 +- .../ai-openrouter/src/adapters/text.ts | 118 +++++++++++++++++- .../ai/src/activities/chat/index.ts | 51 ++++++-- .../src/activities/chat/stream/processor.ts | 68 ++++++++-- .../src/activities/chat/tools/tool-calls.ts | 7 +- .../ai/src/activities/generateVideo/index.ts | 19 +-- .../activities/stream-generation-result.ts | 28 +++-- packages/typescript/ai/src/realtime/types.ts | 2 +- packages/typescript/ai/src/types.ts | 9 +- .../ai/tests/stream-processor.test.ts | 49 ++++++-- packages/typescript/ai/tests/test-utils.ts | 33 +++-- 18 files changed, 637 insertions(+), 98 deletions(-) diff --git a/packages/typescript/ai-anthropic/src/adapters/text.ts b/packages/typescript/ai-anthropic/src/adapters/text.ts index 235d9f5b..6f2a524c 100644 --- a/packages/typescript/ai-anthropic/src/adapters/text.ts +++ b/packages/typescript/ai-anthropic/src/adapters/text.ts @@ -129,7 +129,7 @@ export class AnthropicTextAdapter< }, ) - yield* this.processAnthropicStream(stream, options.model, () => + yield* this.processAnthropicStream(stream, options, () => generateId(this.name), ) } catch (error: unknown) { @@ -138,6 +138,8 @@ export class AnthropicTextAdapter< type: 'RUN_ERROR', model: options.model, timestamp: Date.now(), + message: err.message || 'Unknown error occurred', + code: err.code || String(err.status), error: { message: err.message || 'Unknown error occurred', code: err.code || String(err.status), @@ -523,9 +525,10 @@ export class AnthropicTextAdapter< private async *processAnthropicStream( stream: AsyncIterable, - model: string, + options: TextOptions, genId: () => string, ): AsyncIterable { + const model = options.model let accumulatedContent = '' let accumulatedThinking = '' const timestamp = Date.now() @@ -537,8 +540,11 @@ export class AnthropicTextAdapter< // AG-UI lifecycle tracking const runId = genId() + const threadId = options.threadId || genId() const messageId = genId() let stepId: string | null = null + let reasoningMessageId: string | null = null + let hasClosedReasoning = false let hasEmittedRunStarted = false let hasEmittedTextMessageStart = false let hasEmittedRunFinished = false @@ -553,6 +559,7 @@ export class AnthropicTextAdapter< yield { type: 'RUN_STARTED', runId, + threadId, model, timestamp, } @@ -570,10 +577,29 @@ export class AnthropicTextAdapter< }) } else if (event.content_block.type === 'thinking') { accumulatedThinking = '' - // Emit STEP_STARTED for thinking + // Emit REASONING and STEP_STARTED for thinking stepId = genId() + reasoningMessageId = genId() + + // Spec REASONING events + yield { + type: 'REASONING_START', + messageId: reasoningMessageId, + model, + timestamp, + } + yield { + type: 'REASONING_MESSAGE_START', + messageId: reasoningMessageId, + role: 'reasoning' as const, + model, + timestamp, + } + + // Legacy STEP events (kept during transition) yield { type: 'STEP_STARTED', + stepName: stepId, stepId, model, timestamp, @@ -582,6 +608,23 @@ export class AnthropicTextAdapter< } } else if (event.type === 'content_block_delta') { if (event.delta.type === 'text_delta') { + // Close reasoning before text starts + if (reasoningMessageId && !hasClosedReasoning) { + hasClosedReasoning = true + yield { + type: 'REASONING_MESSAGE_END', + messageId: reasoningMessageId, + model, + timestamp, + } + yield { + type: 'REASONING_END', + messageId: reasoningMessageId, + model, + timestamp, + } + } + // Emit TEXT_MESSAGE_START on first text content if (!hasEmittedTextMessageStart) { hasEmittedTextMessageStart = true @@ -607,8 +650,20 @@ export class AnthropicTextAdapter< } else if (event.delta.type === 'thinking_delta') { const delta = event.delta.thinking accumulatedThinking += delta + + // Spec REASONING content event + yield { + type: 'REASONING_MESSAGE_CONTENT', + messageId: reasoningMessageId!, + delta, + model, + timestamp, + } + + // Legacy STEP event yield { type: 'STEP_FINISHED', + stepName: stepId || genId(), stepId: stepId || genId(), model, timestamp, @@ -624,6 +679,7 @@ export class AnthropicTextAdapter< yield { type: 'TOOL_CALL_START', toolCallId: existing.id, + toolCallName: existing.name, toolName: existing.name, model, timestamp, @@ -653,6 +709,7 @@ export class AnthropicTextAdapter< yield { type: 'TOOL_CALL_START', toolCallId: existing.id, + toolCallName: existing.name, toolName: existing.name, model, timestamp, @@ -672,6 +729,7 @@ export class AnthropicTextAdapter< yield { type: 'TOOL_CALL_END', toolCallId: existing.id, + toolCallName: existing.name, toolName: existing.name, model, timestamp, @@ -694,6 +752,23 @@ export class AnthropicTextAdapter< } currentBlockType = null } else if (event.type === 'message_stop') { + // Close reasoning events if still open + if (reasoningMessageId && !hasClosedReasoning) { + hasClosedReasoning = true + yield { + type: 'REASONING_MESSAGE_END', + messageId: reasoningMessageId, + model, + timestamp, + } + yield { + type: 'REASONING_END', + messageId: reasoningMessageId, + model, + timestamp, + } + } + // Only emit RUN_FINISHED from message_stop if message_delta didn't already emit one. // message_delta carries the real stop_reason (tool_use, end_turn, etc.), // while message_stop is just a completion signal. @@ -701,6 +776,7 @@ export class AnthropicTextAdapter< yield { type: 'RUN_FINISHED', runId, + threadId, model, timestamp, finishReason: 'stop', @@ -709,11 +785,30 @@ export class AnthropicTextAdapter< } else if (event.type === 'message_delta') { if (event.delta.stop_reason) { hasEmittedRunFinished = true + + // Close reasoning events if still open + if (reasoningMessageId && !hasClosedReasoning) { + hasClosedReasoning = true + yield { + type: 'REASONING_MESSAGE_END', + messageId: reasoningMessageId, + model, + timestamp, + } + yield { + type: 'REASONING_END', + messageId: reasoningMessageId, + model, + timestamp, + } + } + switch (event.delta.stop_reason) { case 'tool_use': { yield { type: 'RUN_FINISHED', runId, + threadId, model, timestamp, finishReason: 'tool_calls', @@ -733,6 +828,9 @@ export class AnthropicTextAdapter< runId, model, timestamp, + message: + 'The response was cut off because the maximum token limit was reached.', + code: 'max_tokens', error: { message: 'The response was cut off because the maximum token limit was reached.', @@ -745,6 +843,7 @@ export class AnthropicTextAdapter< yield { type: 'RUN_FINISHED', runId, + threadId, model, timestamp, finishReason: 'stop', @@ -769,6 +868,8 @@ export class AnthropicTextAdapter< runId, model, timestamp, + message: err.message || 'Unknown error occurred', + code: err.code || String(err.status), error: { message: err.message || 'Unknown error occurred', code: err.code || String(err.status), diff --git a/packages/typescript/ai-client/tests/test-utils.ts b/packages/typescript/ai-client/tests/test-utils.ts index afedb1cd..1ee37930 100644 --- a/packages/typescript/ai-client/tests/test-utils.ts +++ b/packages/typescript/ai-client/tests/test-utils.ts @@ -118,6 +118,7 @@ export function createTextChunks( const chunks: Array = [] let accumulated = '' const runId = `run-${messageId}` + const threadId = `thread-${messageId}` for (const chunk of text) { accumulated += chunk @@ -128,16 +129,17 @@ export function createTextChunks( timestamp: Date.now(), delta: chunk, content: accumulated, - }) + } as StreamChunk) } chunks.push({ type: 'RUN_FINISHED', runId, + threadId, model, timestamp: Date.now(), finishReason: 'stop', - }) + } as StreamChunk) return chunks } @@ -158,16 +160,17 @@ export function createCustomEventChunks( timestamp: Date.now(), name: event.name, value: event.value, - }) + } as StreamChunk) } chunks.push({ type: 'RUN_FINISHED', runId: 'run-1', + threadId: 'thread-1', model, timestamp: Date.now(), finishReason: 'stop', - }) + } as StreamChunk) return chunks } @@ -192,11 +195,12 @@ export function createToolCallChunks( chunks.push({ type: 'TOOL_CALL_START', toolCallId: toolCall.id, + toolCallName: toolCall.name, toolName: toolCall.name, model, timestamp: Date.now(), index: i, - }) + } as StreamChunk) // TOOL_CALL_ARGS event chunks.push({ @@ -205,7 +209,7 @@ export function createToolCallChunks( model, timestamp: Date.now(), delta: toolCall.arguments, - }) + } as StreamChunk) // Add tool-input-available CUSTOM chunk if requested if (includeToolInputAvailable) { @@ -226,17 +230,18 @@ export function createToolCallChunks( toolName: toolCall.name, input: parsedInput, }, - }) + } as StreamChunk) } } chunks.push({ type: 'RUN_FINISHED', runId, + threadId: `thread-${messageId}`, model, timestamp: Date.now(), finishReason: 'tool_calls', - }) + } as StreamChunk) return chunks } @@ -264,11 +269,12 @@ export function createApprovalToolCallChunks( chunks.push({ type: 'TOOL_CALL_START', toolCallId: toolCall.id, + toolCallName: toolCall.name, toolName: toolCall.name, model, timestamp: Date.now(), index: i, - }) + } as StreamChunk) chunks.push({ type: 'TOOL_CALL_ARGS', @@ -276,15 +282,16 @@ export function createApprovalToolCallChunks( model, timestamp: Date.now(), delta: toolCall.arguments, - }) + } as StreamChunk) chunks.push({ type: 'TOOL_CALL_END', toolCallId: toolCall.id, + toolCallName: toolCall.name, toolName: toolCall.name, model, timestamp: Date.now(), - }) + } as StreamChunk) chunks.push({ type: 'CUSTOM', @@ -297,16 +304,17 @@ export function createApprovalToolCallChunks( input: JSON.parse(toolCall.arguments), approval: { id: toolCall.approvalId, needsApproval: true }, }, - }) + } as StreamChunk) } chunks.push({ type: 'RUN_FINISHED', runId, + threadId: `thread-${messageId}`, model, timestamp: Date.now(), finishReason: 'tool_calls', - }) + } as StreamChunk) return chunks } @@ -330,12 +338,13 @@ export function createThinkingChunks( accumulatedThinking += chunk chunks.push({ type: 'STEP_FINISHED', + stepName: stepId, stepId, model, timestamp: Date.now(), delta: chunk, content: accumulatedThinking, - }) + } as StreamChunk) } // Optionally add text content after thinking @@ -350,17 +359,18 @@ export function createThinkingChunks( timestamp: Date.now(), delta: chunk, content: accumulatedText, - }) + } as StreamChunk) } } chunks.push({ type: 'RUN_FINISHED', runId, + threadId: `thread-${messageId}`, model, timestamp: Date.now(), finishReason: 'stop', - }) + } as StreamChunk) return chunks } diff --git a/packages/typescript/ai-event-client/src/devtools-middleware.ts b/packages/typescript/ai-event-client/src/devtools-middleware.ts index 5d5b44d7..c034c551 100644 --- a/packages/typescript/ai-event-client/src/devtools-middleware.ts +++ b/packages/typescript/ai-event-client/src/devtools-middleware.ts @@ -254,15 +254,16 @@ export function devtoolsMiddleware(): DevtoolsChatMiddleware { } case 'TOOL_CALL_START': { const toolIndex = chunk.index ?? 0 + const toolName = chunk.toolCallName ?? (chunk as any).toolName activeToolCalls.set(chunk.toolCallId, { - toolName: chunk.toolName, + toolName, index: toolIndex, }) aiEventClient.emit('text:chunk:tool-call', { ...base, messageId: localMessageId || undefined, toolCallId: chunk.toolCallId, - toolName: chunk.toolName, + toolName, index: toolIndex, arguments: '', timestamp: Date.now(), @@ -297,7 +298,7 @@ export function devtoolsMiddleware(): DevtoolsChatMiddleware { aiEventClient.emit('text:chunk:done', { ...base, messageId: localMessageId || undefined, - finishReason: chunk.finishReason, + finishReason: chunk.finishReason ?? null, usage: chunk.usage, timestamp: Date.now(), }) diff --git a/packages/typescript/ai-gemini/src/adapters/text.ts b/packages/typescript/ai-gemini/src/adapters/text.ts index 844c4a12..e4c7d9e2 100644 --- a/packages/typescript/ai-gemini/src/adapters/text.ts +++ b/packages/typescript/ai-gemini/src/adapters/text.ts @@ -106,13 +106,17 @@ export class GeminiTextAdapter< const result = await this.client.models.generateContentStream(mappedOptions) - yield* this.processStreamChunks(result, options.model) + yield* this.processStreamChunks(result, options) } catch (error) { const timestamp = Date.now() yield { type: 'RUN_ERROR', model: options.model, timestamp, + message: + error instanceof Error + ? error.message + : 'An unknown error occurred during the chat stream.', error: { message: error instanceof Error @@ -191,8 +195,9 @@ export class GeminiTextAdapter< private async *processStreamChunks( result: AsyncGenerator, - model: string, + options: TextOptions, ): AsyncIterable { + const model = options.model const timestamp = Date.now() let accumulatedContent = '' let accumulatedThinking = '' @@ -210,8 +215,11 @@ export class GeminiTextAdapter< // AG-UI lifecycle tracking const runId = generateId(this.name) + const threadId = options.threadId || generateId(this.name) const messageId = generateId(this.name) let stepId: string | null = null + let reasoningMessageId: string | null = null + let hasClosedReasoning = false let hasEmittedRunStarted = false let hasEmittedTextMessageStart = false let hasEmittedStepStarted = false @@ -223,6 +231,7 @@ export class GeminiTextAdapter< yield { type: 'RUN_STARTED', runId, + threadId, model, timestamp, } @@ -234,12 +243,31 @@ export class GeminiTextAdapter< for (const part of parts) { if (part.text) { if (part.thought) { - // Emit STEP_STARTED on first thinking content + // Emit STEP_STARTED and REASONING events on first thinking content if (!hasEmittedStepStarted) { hasEmittedStepStarted = true stepId = generateId(this.name) + reasoningMessageId = generateId(this.name) + + // Spec REASONING events + yield { + type: 'REASONING_START', + messageId: reasoningMessageId, + model, + timestamp, + } + yield { + type: 'REASONING_MESSAGE_START', + messageId: reasoningMessageId, + role: 'reasoning' as const, + model, + timestamp, + } + + // Legacy STEP events (kept during transition) yield { type: 'STEP_STARTED', + stepName: stepId, stepId, model, timestamp, @@ -248,8 +276,20 @@ export class GeminiTextAdapter< } accumulatedThinking += part.text + + // Spec REASONING content event + yield { + type: 'REASONING_MESSAGE_CONTENT', + messageId: reasoningMessageId!, + delta: part.text, + model, + timestamp, + } + + // Legacy STEP event yield { type: 'STEP_FINISHED', + stepName: stepId || generateId(this.name), stepId: stepId || generateId(this.name), model, timestamp, @@ -257,6 +297,23 @@ export class GeminiTextAdapter< content: accumulatedThinking, } } else if (part.text.trim()) { + // Close reasoning before text starts + if (reasoningMessageId && !hasClosedReasoning) { + hasClosedReasoning = true + yield { + type: 'REASONING_MESSAGE_END', + messageId: reasoningMessageId, + model, + timestamp, + } + yield { + type: 'REASONING_END', + messageId: reasoningMessageId, + model, + timestamp, + } + } + // Skip whitespace-only text parts (e.g. "\n" during auto-continuation) // Emit TEXT_MESSAGE_START on first text content if (!hasEmittedTextMessageStart) { @@ -326,6 +383,7 @@ export class GeminiTextAdapter< yield { type: 'TOOL_CALL_START', toolCallId, + toolCallName: toolCallData.name, toolName: toolCallData.name, model, timestamp, @@ -403,6 +461,7 @@ export class GeminiTextAdapter< yield { type: 'TOOL_CALL_START', toolCallId, + toolCallName: functionCall.name || '', toolName: functionCall.name || '', model, timestamp, @@ -423,6 +482,7 @@ export class GeminiTextAdapter< yield { type: 'TOOL_CALL_END', toolCallId, + toolCallName: functionCall.name || '', toolName: functionCall.name || '', model, timestamp, @@ -445,6 +505,7 @@ export class GeminiTextAdapter< yield { type: 'TOOL_CALL_END', toolCallId, + toolCallName: toolCallData.name, toolName: toolCallData.name, model, timestamp, @@ -463,6 +524,9 @@ export class GeminiTextAdapter< runId, model, timestamp, + message: + 'The response was cut off because the maximum token limit was reached.', + code: 'max_tokens', error: { message: 'The response was cut off because the maximum token limit was reached.', @@ -471,6 +535,23 @@ export class GeminiTextAdapter< } } + // Close reasoning events if still open + if (reasoningMessageId && !hasClosedReasoning) { + hasClosedReasoning = true + yield { + type: 'REASONING_MESSAGE_END', + messageId: reasoningMessageId, + model, + timestamp, + } + yield { + type: 'REASONING_END', + messageId: reasoningMessageId, + model, + timestamp, + } + } + // Emit TEXT_MESSAGE_END if we had text content if (hasEmittedTextMessageStart) { yield { @@ -484,6 +565,7 @@ export class GeminiTextAdapter< yield { type: 'RUN_FINISHED', runId, + threadId, model, timestamp, finishReason: toolCallMap.size > 0 ? 'tool_calls' : 'stop', diff --git a/packages/typescript/ai-grok/src/adapters/text.ts b/packages/typescript/ai-grok/src/adapters/text.ts index c0204ab5..000f50af 100644 --- a/packages/typescript/ai-grok/src/adapters/text.ts +++ b/packages/typescript/ai-grok/src/adapters/text.ts @@ -74,6 +74,7 @@ export class GrokTextAdapter< // AG-UI lifecycle tracking (mutable state object for ESLint compatibility) const aguiState = { runId: generateId(this.name), + threadId: options.threadId || generateId(this.name), messageId: generateId(this.name), timestamp, hasEmittedRunStarted: false, @@ -95,6 +96,7 @@ export class GrokTextAdapter< yield { type: 'RUN_STARTED', runId: aguiState.runId, + threadId: aguiState.threadId, model: options.model, timestamp, } @@ -106,6 +108,8 @@ export class GrokTextAdapter< runId: aguiState.runId, model: options.model, timestamp, + message: err.message || 'Unknown error', + code: err.code, error: { message: err.message || 'Unknown error', code: err.code, @@ -191,6 +195,7 @@ export class GrokTextAdapter< options: TextOptions, aguiState: { runId: string + threadId: string messageId: string timestamp: number hasEmittedRunStarted: boolean @@ -223,6 +228,7 @@ export class GrokTextAdapter< yield { type: 'RUN_STARTED', runId: aguiState.runId, + threadId: aguiState.threadId, model: chunk.model || options.model, timestamp, } @@ -293,6 +299,7 @@ export class GrokTextAdapter< yield { type: 'TOOL_CALL_START', toolCallId: toolCall.id, + toolCallName: toolCall.name, toolName: toolCall.name, model: chunk.model || options.model, timestamp, @@ -335,6 +342,7 @@ export class GrokTextAdapter< yield { type: 'TOOL_CALL_END', toolCallId: toolCall.id, + toolCallName: toolCall.name, toolName: toolCall.name, model: chunk.model || options.model, timestamp, @@ -363,6 +371,7 @@ export class GrokTextAdapter< yield { type: 'RUN_FINISHED', runId: aguiState.runId, + threadId: aguiState.threadId, model: chunk.model || options.model, timestamp, usage: chunk.usage @@ -386,6 +395,8 @@ export class GrokTextAdapter< runId: aguiState.runId, model: options.model, timestamp, + message: err.message || 'Unknown error occurred', + code: err.code, error: { message: err.message || 'Unknown error occurred', code: err.code, diff --git a/packages/typescript/ai-groq/src/adapters/text.ts b/packages/typescript/ai-groq/src/adapters/text.ts index 6e6465f7..8d5f588c 100644 --- a/packages/typescript/ai-groq/src/adapters/text.ts +++ b/packages/typescript/ai-groq/src/adapters/text.ts @@ -76,6 +76,7 @@ export class GroqTextAdapter< const aguiState = { runId: generateId(this.name), + threadId: options.threadId || generateId(this.name), messageId: generateId(this.name), timestamp, hasEmittedRunStarted: false, @@ -96,6 +97,7 @@ export class GroqTextAdapter< yield { type: 'RUN_STARTED', runId: aguiState.runId, + threadId: aguiState.threadId, model: options.model, timestamp, } @@ -106,6 +108,8 @@ export class GroqTextAdapter< runId: aguiState.runId, model: options.model, timestamp, + message: err.message || 'Unknown error', + code: err.code, error: { message: err.message || 'Unknown error', code: err.code, @@ -190,6 +194,7 @@ export class GroqTextAdapter< options: TextOptions, aguiState: { runId: string + threadId: string messageId: string timestamp: number hasEmittedRunStarted: boolean @@ -220,6 +225,7 @@ export class GroqTextAdapter< yield { type: 'RUN_STARTED', runId: aguiState.runId, + threadId: aguiState.threadId, model: chunk.model || options.model, timestamp, } @@ -283,6 +289,7 @@ export class GroqTextAdapter< yield { type: 'TOOL_CALL_START', toolCallId: toolCall.id, + toolCallName: toolCall.name, toolName: toolCall.name, model: chunk.model || options.model, timestamp, @@ -324,6 +331,7 @@ export class GroqTextAdapter< yield { type: 'TOOL_CALL_END', toolCallId: toolCall.id, + toolCallName: toolCall.name, toolName: toolCall.name, model: chunk.model || options.model, timestamp, @@ -354,6 +362,7 @@ export class GroqTextAdapter< yield { type: 'RUN_FINISHED', runId: aguiState.runId, + threadId: aguiState.threadId, model: chunk.model || options.model, timestamp, usage: groqUsage @@ -376,6 +385,8 @@ export class GroqTextAdapter< runId: aguiState.runId, model: options.model, timestamp, + message: err.message || 'Unknown error occurred', + code: err.code, error: { message: err.message || 'Unknown error occurred', code: err.code, diff --git a/packages/typescript/ai-ollama/src/adapters/text.ts b/packages/typescript/ai-ollama/src/adapters/text.ts index 624b6c97..875b7667 100644 --- a/packages/typescript/ai-ollama/src/adapters/text.ts +++ b/packages/typescript/ai-ollama/src/adapters/text.ts @@ -134,7 +134,7 @@ export class OllamaTextAdapter extends BaseTextAdapter< ...mappedOptions, stream: true, }) - yield* this.processOllamaStreamChunks(response) + yield* this.processOllamaStreamChunks(response, options) } /** @@ -183,6 +183,7 @@ export class OllamaTextAdapter extends BaseTextAdapter< private async *processOllamaStreamChunks( stream: AbortableAsyncIterator, + options: TextOptions, ): AsyncIterable { let accumulatedContent = '' const timestamp = Date.now() @@ -191,8 +192,11 @@ export class OllamaTextAdapter extends BaseTextAdapter< // AG-UI lifecycle tracking const runId = generateId('run') + const threadId = options.threadId || generateId('thread') const messageId = generateId('msg') let stepId: string | null = null + let reasoningMessageId: string | null = null + let hasClosedReasoning = false let hasEmittedRunStarted = false let hasEmittedTextMessageStart = false let hasEmittedStepStarted = false @@ -204,6 +208,7 @@ export class OllamaTextAdapter extends BaseTextAdapter< yield { type: 'RUN_STARTED', runId, + threadId, model: chunk.model, timestamp, } @@ -224,6 +229,7 @@ export class OllamaTextAdapter extends BaseTextAdapter< events.push({ type: 'TOOL_CALL_START', toolCallId, + toolCallName: actualToolCall.function.name || '', toolName: actualToolCall.function.name || '', model: chunk.model, timestamp, @@ -260,6 +266,7 @@ export class OllamaTextAdapter extends BaseTextAdapter< events.push({ type: 'TOOL_CALL_END', toolCallId, + toolCallName: actualToolCall.function.name || '', toolName: actualToolCall.function.name || '', model: chunk.model, timestamp, @@ -279,6 +286,23 @@ export class OllamaTextAdapter extends BaseTextAdapter< } } + // Close reasoning events if still open + if (reasoningMessageId && !hasClosedReasoning) { + hasClosedReasoning = true + yield { + type: 'REASONING_MESSAGE_END', + messageId: reasoningMessageId, + model: chunk.model, + timestamp, + } + yield { + type: 'REASONING_END', + messageId: reasoningMessageId, + model: chunk.model, + timestamp, + } + } + // Emit TEXT_MESSAGE_END if we had text content if (hasEmittedTextMessageStart) { yield { @@ -292,6 +316,7 @@ export class OllamaTextAdapter extends BaseTextAdapter< yield { type: 'RUN_FINISHED', runId, + threadId, model: chunk.model, timestamp, finishReason: toolCallsEmitted.size > 0 ? 'tool_calls' : 'stop', @@ -306,6 +331,23 @@ export class OllamaTextAdapter extends BaseTextAdapter< } if (chunk.message.content) { + // Close reasoning before text starts + if (reasoningMessageId && !hasClosedReasoning) { + hasClosedReasoning = true + yield { + type: 'REASONING_MESSAGE_END', + messageId: reasoningMessageId, + model: chunk.model, + timestamp, + } + yield { + type: 'REASONING_END', + messageId: reasoningMessageId, + model: chunk.model, + timestamp, + } + } + // Emit TEXT_MESSAGE_START on first text content if (!hasEmittedTextMessageStart) { hasEmittedTextMessageStart = true @@ -339,12 +381,31 @@ export class OllamaTextAdapter extends BaseTextAdapter< } if (chunk.message.thinking) { - // Emit STEP_STARTED on first thinking content + // Emit STEP_STARTED and REASONING events on first thinking content if (!hasEmittedStepStarted) { hasEmittedStepStarted = true stepId = generateId('step') + reasoningMessageId = generateId('msg') + + // Spec REASONING events + yield { + type: 'REASONING_START', + messageId: reasoningMessageId, + model: chunk.model, + timestamp, + } + yield { + type: 'REASONING_MESSAGE_START', + messageId: reasoningMessageId, + role: 'reasoning' as const, + model: chunk.model, + timestamp, + } + + // Legacy STEP events (kept during transition) yield { type: 'STEP_STARTED', + stepName: stepId, stepId, model: chunk.model, timestamp, @@ -353,8 +414,20 @@ export class OllamaTextAdapter extends BaseTextAdapter< } accumulatedReasoning += chunk.message.thinking + + // Spec REASONING content event + yield { + type: 'REASONING_MESSAGE_CONTENT', + messageId: reasoningMessageId!, + delta: chunk.message.thinking, + model: chunk.model, + timestamp, + } + + // Legacy STEP event yield { type: 'STEP_FINISHED', + stepName: stepId || generateId('step'), stepId: stepId || generateId('step'), model: chunk.model, timestamp, diff --git a/packages/typescript/ai-openai/src/realtime/adapter.ts b/packages/typescript/ai-openai/src/realtime/adapter.ts index 1207bf97..b120f2a1 100644 --- a/packages/typescript/ai-openai/src/realtime/adapter.ts +++ b/packages/typescript/ai-openai/src/realtime/adapter.ts @@ -284,9 +284,13 @@ async function createWebRTCConnection( } try { const input = JSON.parse(args) - emit('tool_call', { toolCallId: callId, toolName: name, input }) + emit('tool_call', { toolCallId: callId, toolCallName: name, input }) } catch { - emit('tool_call', { toolCallId: callId, toolName: name, input: args }) + emit('tool_call', { + toolCallId: callId, + toolCallName: name, + input: args, + }) } break } diff --git a/packages/typescript/ai-openrouter/src/adapters/text.ts b/packages/typescript/ai-openrouter/src/adapters/text.ts index 76733e91..fa1d82f4 100644 --- a/packages/typescript/ai-openrouter/src/adapters/text.ts +++ b/packages/typescript/ai-openrouter/src/adapters/text.ts @@ -64,8 +64,11 @@ interface ToolCallBuffer { // AG-UI lifecycle state tracking interface AGUIState { runId: string + threadId: string messageId: string stepId: string | null + reasoningMessageId: string | null + hasClosedReasoning: boolean hasEmittedRunStarted: boolean hasEmittedTextMessageStart: boolean hasEmittedStepStarted: boolean @@ -101,8 +104,11 @@ export class OpenRouterTextAdapter< // AG-UI lifecycle tracking const aguiState: AGUIState = { runId: this.generateId(), + threadId: options.threadId || this.generateId(), messageId: this.generateId(), stepId: null, + reasoningMessageId: null, + hasClosedReasoning: false, hasEmittedRunStarted: false, hasEmittedTextMessageStart: false, hasEmittedStepStarted: false, @@ -125,6 +131,7 @@ export class OpenRouterTextAdapter< yield { type: 'RUN_STARTED', runId: aguiState.runId, + threadId: aguiState.threadId, model: currentModel || options.model, timestamp, } @@ -137,6 +144,8 @@ export class OpenRouterTextAdapter< runId: aguiState.runId, model: currentModel || options.model, timestamp, + message: chunk.error.message || 'Unknown error', + code: String(chunk.error.code), error: { message: chunk.error.message || 'Unknown error', code: String(chunk.error.code), @@ -171,6 +180,7 @@ export class OpenRouterTextAdapter< yield { type: 'RUN_STARTED', runId: aguiState.runId, + threadId: aguiState.threadId, model: options.model, timestamp, } @@ -183,6 +193,8 @@ export class OpenRouterTextAdapter< runId: aguiState.runId, model: options.model, timestamp, + message: 'Request aborted', + code: 'aborted', error: { message: 'Request aborted', code: 'aborted', @@ -197,6 +209,7 @@ export class OpenRouterTextAdapter< runId: aguiState.runId, model: options.model, timestamp, + message: (error as Error).message || 'Unknown error', error: { message: (error as Error).message || 'Unknown error', }, @@ -288,6 +301,23 @@ export class OpenRouterTextAdapter< const finishReason = choice.finishReason if (delta.content) { + // Close reasoning before text starts + if (aguiState.reasoningMessageId && !aguiState.hasClosedReasoning) { + aguiState.hasClosedReasoning = true + yield { + type: 'REASONING_MESSAGE_END', + messageId: aguiState.reasoningMessageId, + model: meta.model, + timestamp: meta.timestamp, + } + yield { + type: 'REASONING_END', + messageId: aguiState.reasoningMessageId, + model: meta.model, + timestamp: meta.timestamp, + } + } + // Emit TEXT_MESSAGE_START on first text content if (!aguiState.hasEmittedTextMessageStart) { aguiState.hasEmittedTextMessageStart = true @@ -319,12 +349,31 @@ export class OpenRouterTextAdapter< if (detail.type === 'reasoning.text') { const text = detail.text || '' - // Emit STEP_STARTED on first reasoning content + // Emit STEP_STARTED and REASONING events on first reasoning content if (!aguiState.hasEmittedStepStarted) { aguiState.hasEmittedStepStarted = true aguiState.stepId = this.generateId() + aguiState.reasoningMessageId = this.generateId() + + // Spec REASONING events + yield { + type: 'REASONING_START', + messageId: aguiState.reasoningMessageId, + model: meta.model, + timestamp: meta.timestamp, + } + yield { + type: 'REASONING_MESSAGE_START', + messageId: aguiState.reasoningMessageId, + role: 'reasoning' as const, + model: meta.model, + timestamp: meta.timestamp, + } + + // Legacy STEP events (kept during transition) yield { type: 'STEP_STARTED', + stepName: aguiState.stepId, stepId: aguiState.stepId, model: meta.model, timestamp: meta.timestamp, @@ -335,9 +384,19 @@ export class OpenRouterTextAdapter< accumulated.reasoning += text updateAccumulated(accumulated.reasoning, accumulated.content) - // Emit AG-UI STEP_FINISHED for reasoning delta + // Spec REASONING content event + yield { + type: 'REASONING_MESSAGE_CONTENT', + messageId: aguiState.reasoningMessageId!, + delta: text, + model: meta.model, + timestamp: meta.timestamp, + } + + // Legacy STEP event yield { type: 'STEP_FINISHED', + stepName: aguiState.stepId!, stepId: aguiState.stepId!, model: meta.model, timestamp: meta.timestamp, @@ -349,12 +408,31 @@ export class OpenRouterTextAdapter< if (detail.type === 'reasoning.summary') { const text = detail.summary || '' - // Emit STEP_STARTED on first reasoning content + // Emit STEP_STARTED and REASONING events on first reasoning content if (!aguiState.hasEmittedStepStarted) { aguiState.hasEmittedStepStarted = true aguiState.stepId = this.generateId() + aguiState.reasoningMessageId = this.generateId() + + // Spec REASONING events + yield { + type: 'REASONING_START', + messageId: aguiState.reasoningMessageId, + model: meta.model, + timestamp: meta.timestamp, + } + yield { + type: 'REASONING_MESSAGE_START', + messageId: aguiState.reasoningMessageId, + role: 'reasoning' as const, + model: meta.model, + timestamp: meta.timestamp, + } + + // Legacy STEP events (kept during transition) yield { type: 'STEP_STARTED', + stepName: aguiState.stepId, stepId: aguiState.stepId, model: meta.model, timestamp: meta.timestamp, @@ -365,9 +443,19 @@ export class OpenRouterTextAdapter< accumulated.reasoning += text updateAccumulated(accumulated.reasoning, accumulated.content) - // Emit AG-UI STEP_FINISHED for reasoning delta + // Spec REASONING content event + yield { + type: 'REASONING_MESSAGE_CONTENT', + messageId: aguiState.reasoningMessageId!, + delta: text, + model: meta.model, + timestamp: meta.timestamp, + } + + // Legacy STEP event yield { type: 'STEP_FINISHED', + stepName: aguiState.stepId!, stepId: aguiState.stepId!, model: meta.model, timestamp: meta.timestamp, @@ -407,6 +495,7 @@ export class OpenRouterTextAdapter< yield { type: 'TOOL_CALL_START', toolCallId: buffer.id, + toolCallName: buffer.name, toolName: buffer.name, model: meta.model, timestamp: meta.timestamp, @@ -434,6 +523,8 @@ export class OpenRouterTextAdapter< runId: aguiState.runId, model: meta.model, timestamp: meta.timestamp, + message: delta.refusal, + code: 'refusal', error: { message: delta.refusal, code: 'refusal' }, } } @@ -454,6 +545,7 @@ export class OpenRouterTextAdapter< yield { type: 'TOOL_CALL_END', toolCallId: tc.id, + toolCallName: tc.name, toolName: tc.name, model: meta.model, timestamp: meta.timestamp, @@ -471,6 +563,23 @@ export class OpenRouterTextAdapter< ? 'length' : 'stop' + // Close reasoning events if still open + if (aguiState.reasoningMessageId && !aguiState.hasClosedReasoning) { + aguiState.hasClosedReasoning = true + yield { + type: 'REASONING_MESSAGE_END', + messageId: aguiState.reasoningMessageId, + model: meta.model, + timestamp: meta.timestamp, + } + yield { + type: 'REASONING_END', + messageId: aguiState.reasoningMessageId, + model: meta.model, + timestamp: meta.timestamp, + } + } + // Emit TEXT_MESSAGE_END if we had text content if (aguiState.hasEmittedTextMessageStart) { yield { @@ -485,6 +594,7 @@ export class OpenRouterTextAdapter< yield { type: 'RUN_FINISHED', runId: aguiState.runId, + threadId: aguiState.threadId, model: meta.model, timestamp: meta.timestamp, usage: usage diff --git a/packages/typescript/ai/src/activities/chat/index.ts b/packages/typescript/ai/src/activities/chat/index.ts index 96d0be23..6f9e294b 100644 --- a/packages/typescript/ai/src/activities/chat/index.ts +++ b/packages/typescript/ai/src/activities/chat/index.ts @@ -6,7 +6,6 @@ */ import { devtoolsMiddleware } from '@tanstack/ai-event-client' -import { stripToSpecMiddleware } from '../../strip-to-spec-middleware' import { streamToText } from '../../stream-to-response.js' import { LazyToolManager } from './tools/lazy-tool-manager' import { @@ -320,7 +319,11 @@ class TextEngine< this.runIdOverride = config.params.runId // Initialize middleware — devtools middleware is always first - const allMiddleware = [devtoolsMiddleware(), ...(config.middleware || []), stripToSpecMiddleware()] + // Note: stripToSpecMiddleware is NOT auto-applied here. The JS consumer + // (StreamProcessor, user code) needs TanStack-specific fields like + // finishReason, delta, content, toolName, etc. Strip-to-spec should be + // applied explicitly when sending events over the wire (SSE/HTTP transport). + const allMiddleware = [devtoolsMiddleware(), ...(config.middleware || [])] this.middlewareRunner = new MiddlewareRunner(allMiddleware) this.middlewareAbortController = new AbortController() this.middlewareCtx = { @@ -555,14 +558,17 @@ class TextEngine< this.totalChunkCount++ - // Pipe chunk through middleware (devtools middleware observes and emits events) + // Process the original (unstripped) chunk for internal state management + // BEFORE middleware, so fields like finishReason, delta, etc. are available + this.handleStreamChunk(chunk) + + // Pipe chunk through middleware (devtools middleware observes; strip-to-spec cleans) const outputChunks = await this.middlewareRunner.runOnChunk( this.middlewareCtx, chunk, ) for (const outputChunk of outputChunks) { yield outputChunk - this.handleStreamChunk(outputChunk) this.middlewareCtx.chunkIndex++ } @@ -602,6 +608,18 @@ class TextEngine< this.handleStepFinishedEvent(chunk) break + case 'TOOL_CALL_RESULT': + // Tool result is already added to messages in buildToolResultChunks + break + + case 'REASONING_START': + case 'REASONING_MESSAGE_START': + case 'REASONING_MESSAGE_CONTENT': + case 'REASONING_MESSAGE_END': + case 'REASONING_END': + // Reasoning events are handled by StreamProcessor + break + default: // RUN_STARTED, TEXT_MESSAGE_START, TEXT_MESSAGE_END, STEP_STARTED, // STATE_SNAPSHOT, STATE_DELTA, CUSTOM @@ -637,7 +655,7 @@ class TextEngine< private handleRunFinishedEvent(chunk: RunFinishedEvent): void { this.finishedEvent = chunk - this.lastFinishReason = chunk.finishReason + this.lastFinishReason = chunk.finishReason ?? null } private handleRunErrorEvent( @@ -1061,7 +1079,7 @@ class TextEngine< needsApproval: true, }, }, - }) + } as StreamChunk) } return chunks @@ -1084,7 +1102,7 @@ class TextEngine< toolName: clientTool.toolName, input: clientTool.input, }, - }) + } as StreamChunk) } return chunks @@ -1104,9 +1122,21 @@ class TextEngine< timestamp: Date.now(), model: finishEvent.model, toolCallId: result.toolCallId, + toolCallName: result.toolName, toolName: result.toolName, result: content, - }) + } as StreamChunk) + + // AG-UI spec TOOL_CALL_RESULT event + chunks.push({ + type: 'TOOL_CALL_RESULT', + timestamp: Date.now(), + model: finishEvent.model, + messageId: this.createId('tool-result'), + toolCallId: result.toolCallId, + content, + role: 'tool', + } as StreamChunk) this.messages = [ ...this.messages, @@ -1167,10 +1197,11 @@ class TextEngine< return { type: 'RUN_FINISHED', runId: this.createId('pending'), + threadId: this.params.threadId ?? this.createId('thread'), model: this.params.model, timestamp: Date.now(), finishReason: 'tool_calls', - } + } as RunFinishedEvent } private shouldContinue(): boolean { @@ -1278,7 +1309,7 @@ class TextEngine< model: this.params.model, name: eventName, value, - } + } as CustomEvent } private createId(prefix: string): string { diff --git a/packages/typescript/ai/src/activities/chat/stream/processor.ts b/packages/typescript/ai/src/activities/chat/stream/processor.ts index caed6a74..22e74d35 100644 --- a/packages/typescript/ai/src/activities/chat/stream/processor.ts +++ b/packages/typescript/ai/src/activities/chat/stream/processor.ts @@ -495,6 +495,21 @@ export class StreamProcessor { this.handleRunStartedEvent(chunk) break + case 'REASONING_START': + case 'REASONING_MESSAGE_START': + case 'REASONING_MESSAGE_END': + case 'REASONING_END': + // No special handling needed + break + + case 'REASONING_MESSAGE_CONTENT': + this.handleReasoningMessageContentEvent(chunk) + break + + case 'TOOL_CALL_RESULT': + // Tool result handled by chat activity + break + default: // STEP_STARTED, STATE_SNAPSHOT, STATE_DELTA - no special handling needed break @@ -632,11 +647,11 @@ export class StreamProcessor { ): void { const { messageId, role } = chunk - // Map 'tool' role to 'assistant' for both UIMessage and MessageStreamState - // (UIMessage doesn't support 'tool' role, and lookups like + // Map 'tool' and 'developer' roles to 'assistant' for both UIMessage and MessageStreamState + // (UIMessage doesn't support 'tool'/'developer' role, and lookups like // getActiveAssistantMessageId() check state.role === 'assistant') const uiRole: 'system' | 'user' | 'assistant' = - role === 'tool' ? 'assistant' : role + role === 'user' || role === 'system' ? role : 'assistant' // Case 1: A manual message was created via startAssistantMessage() if (this.pendingManualMessageId) { @@ -739,7 +754,8 @@ export class StreamProcessor { chunk: Extract, ): void { this.resetStreamState() - this.messages = [...chunk.messages] + // AG-UI Message[] is compatible with UIMessage[] at runtime + this.messages = [...chunk.messages] as unknown as UIMessage[] this.emitMessagesChange() } @@ -849,9 +865,12 @@ export class StreamProcessor { // New tool call starting const initialState: ToolCallState = 'awaiting-input' + // Prefer spec field `toolCallName`; fall back to deprecated `toolName` + const toolName = chunk.toolCallName ?? (chunk as any).toolName + const newToolCall: InternalToolCallState = { id: chunk.toolCallId, - name: chunk.toolName, + name: toolName, arguments: '', state: initialState, parsedArguments: undefined, @@ -867,7 +886,7 @@ export class StreamProcessor { // Update UIMessage this.messages = updateToolCallPart(this.messages, messageId, { id: chunk.toolCallId, - name: chunk.toolName, + name: toolName, arguments: '', state: initialState, }) @@ -1037,7 +1056,7 @@ export class StreamProcessor { private handleRunFinishedEvent( chunk: Extract, ): void { - this.finishReason = chunk.finishReason + this.finishReason = chunk.finishReason ?? null this.activeRuns.delete(chunk.runId) if (this.activeRuns.size === 0) { @@ -1054,13 +1073,17 @@ export class StreamProcessor { chunk: Extract, ): void { this.hasError = true - if (chunk.runId) { - this.activeRuns.delete(chunk.runId) + const runId = (chunk as any).runId as string | undefined + if (runId) { + this.activeRuns.delete(runId) } else { this.activeRuns.clear() } this.ensureAssistantMessage() - this.events.onError?.(new Error(chunk.error.message || 'An error occurred')) + // Prefer spec field `message`; fall back to deprecated `error.message` + const errorMessage = + chunk.message || chunk.error?.message || 'An error occurred' + this.events.onError?.(new Error(errorMessage)) } /** @@ -1108,6 +1131,31 @@ export class StreamProcessor { this.events.onThinkingUpdate?.(messageId, state.thinkingContent) } + /** + * Handle REASONING_MESSAGE_CONTENT event (AG-UI reasoning protocol). + * + * Accumulates reasoning delta into thinkingContent and updates the ThinkingPart + * in the UIMessage. + */ + private handleReasoningMessageContentEvent( + chunk: Extract, + ): void { + const { messageId, state } = this.ensureAssistantMessage( + this.getActiveAssistantMessageId() ?? undefined, + ) + + state.thinkingContent = state.thinkingContent + chunk.delta + + this.messages = updateThinkingPart( + this.messages, + messageId, + state.thinkingContent, + ) + this.emitMessagesChange() + + this.events.onThinkingUpdate?.(messageId, state.thinkingContent) + } + /** * Handle CUSTOM event. * diff --git a/packages/typescript/ai/src/activities/chat/tools/tool-calls.ts b/packages/typescript/ai/src/activities/chat/tools/tool-calls.ts index c05b759c..5be684a2 100644 --- a/packages/typescript/ai/src/activities/chat/tools/tool-calls.ts +++ b/packages/typescript/ai/src/activities/chat/tools/tool-calls.ts @@ -93,11 +93,13 @@ export class ToolCallManager { */ addToolCallStartEvent(event: ToolCallStartEvent): void { const index = event.index ?? this.toolCallsMap.size + // Prefer spec field `toolCallName`; fall back to deprecated `toolName` + const name = event.toolCallName ?? event.toolName this.toolCallsMap.set(index, { id: event.toolCallId, type: 'function', function: { - name: event.toolName, + name, arguments: '', }, ...(event.providerMetadata && { @@ -233,11 +235,12 @@ export class ToolCallManager { yield { type: 'TOOL_CALL_END', toolCallId: toolCall.id, + toolCallName: toolCall.function.name, toolName: toolCall.function.name, model: finishEvent.model, timestamp: Date.now(), result: toolResultContent, - } + } as ToolCallEndEvent // Add tool result message toolResults.push({ diff --git a/packages/typescript/ai/src/activities/generateVideo/index.ts b/packages/typescript/ai/src/activities/generateVideo/index.ts index 7f761ee7..375cf92b 100644 --- a/packages/typescript/ai/src/activities/generateVideo/index.ts +++ b/packages/typescript/ai/src/activities/generateVideo/index.ts @@ -268,11 +268,14 @@ async function* runStreamingVideoGeneration< const pollingInterval = options.pollingInterval ?? 2000 const maxDuration = options.maxDuration ?? 600_000 + const threadId = createId('thread') + yield { type: 'RUN_STARTED', runId, + threadId, timestamp: Date.now(), - } + } as StreamChunk try { // Create the video generation job @@ -289,7 +292,7 @@ async function* runStreamingVideoGeneration< name: 'video:job:created', value: { jobId: jobResult.jobId }, timestamp: Date.now(), - } + } as StreamChunk // Poll for completion const startTime = Date.now() @@ -308,7 +311,7 @@ async function* runStreamingVideoGeneration< error: statusResult.error, }, timestamp: Date.now(), - } + } as StreamChunk if (statusResult.status === 'completed') { const urlResult = await adapter.getVideoUrl(jobResult.jobId) @@ -323,14 +326,15 @@ async function* runStreamingVideoGeneration< expiresAt: urlResult.expiresAt, }, timestamp: Date.now(), - } + } as StreamChunk yield { type: 'RUN_FINISHED', runId, + threadId, finishReason: 'stop', timestamp: Date.now(), - } + } as StreamChunk return } @@ -343,13 +347,14 @@ async function* runStreamingVideoGeneration< } catch (error: any) { yield { type: 'RUN_ERROR', - runId, + message: error.message || 'Video generation failed', + code: error.code, error: { message: error.message || 'Video generation failed', code: error.code, }, timestamp: Date.now(), - } + } as StreamChunk } } diff --git a/packages/typescript/ai/src/activities/stream-generation-result.ts b/packages/typescript/ai/src/activities/stream-generation-result.ts index abf86e1e..6352dc63 100644 --- a/packages/typescript/ai/src/activities/stream-generation-result.ts +++ b/packages/typescript/ai/src/activities/stream-generation-result.ts @@ -4,6 +4,7 @@ * implementations to support `stream: true`. */ +import { EventType } from '@ag-ui/core' import type { StreamChunk } from '../types' function createId(prefix: string): string { @@ -17,46 +18,51 @@ function createId(prefix: string): string { * to be sent over the same streaming transport as chat. * * @param generator - An async function that performs the generation and returns the result - * @param options - Optional configuration (runId) + * @param options - Optional configuration (runId, threadId) * @returns An AsyncIterable of StreamChunks with RUN_STARTED, CUSTOM(generation:result), and RUN_FINISHED events */ export async function* streamGenerationResult( generator: () => Promise, - options?: { runId?: string }, + options?: { runId?: string; threadId?: string }, ): AsyncIterable { const runId = options?.runId ?? createId('run') + const threadId = options?.threadId ?? createId('thread') yield { - type: 'RUN_STARTED', + type: EventType.RUN_STARTED, runId, + threadId, timestamp: Date.now(), - } + } as StreamChunk try { const result = await generator() yield { - type: 'CUSTOM', + type: EventType.CUSTOM, name: 'generation:result', value: result as unknown, timestamp: Date.now(), - } + } as StreamChunk yield { - type: 'RUN_FINISHED', + type: EventType.RUN_FINISHED, runId, + threadId, finishReason: 'stop', timestamp: Date.now(), - } + } as StreamChunk } catch (error: any) { yield { - type: 'RUN_ERROR', - runId, + type: EventType.RUN_ERROR, + message: error.message || 'Generation failed', + code: error.code, + // Deprecated nested form for backward compatibility error: { message: error.message || 'Generation failed', code: error.code, }, timestamp: Date.now(), - } + } as StreamChunk } } diff --git a/packages/typescript/ai/src/realtime/types.ts b/packages/typescript/ai/src/realtime/types.ts index daaf6f57..97423b32 100644 --- a/packages/typescript/ai/src/realtime/types.ts +++ b/packages/typescript/ai/src/realtime/types.ts @@ -257,7 +257,7 @@ export interface RealtimeEventPayloads { isFinal: boolean } audio_chunk: { data: ArrayBuffer; sampleRate: number } - tool_call: { toolCallId: string; toolName: string; input: unknown } + tool_call: { toolCallId: string; toolCallName: string; input: unknown } message_complete: { message: RealtimeMessage } interrupted: { messageId?: string } error: { error: Error } diff --git a/packages/typescript/ai/src/types.ts b/packages/typescript/ai/src/types.ts index 172524ae..3f0c285f 100644 --- a/packages/typescript/ai/src/types.ts +++ b/packages/typescript/ai/src/types.ts @@ -913,12 +913,17 @@ export interface ToolCallArgsEvent extends AGUIToolCallArgsEvent { * Emitted when a tool call completes. * * @ag-ui/core provides: `toolCallId` - * TanStack AI adds: `model?`, `toolName?`, `input?`, `result?` + * TanStack AI adds: `model?`, `toolCallName?`, `toolName?` (deprecated), `input?`, `result?` */ export interface ToolCallEndEvent extends AGUIToolCallEndEvent { /** Model identifier for multi-model support */ model?: string - /** Name of the tool (TanStack AI internal) */ + /** Name of the tool that completed */ + toolCallName?: string + /** + * @deprecated Use `toolCallName` instead. + * Kept for backward compatibility. + */ toolName?: string /** Final parsed input arguments (TanStack AI internal) */ input?: unknown diff --git a/packages/typescript/ai/tests/stream-processor.test.ts b/packages/typescript/ai/tests/stream-processor.test.ts index 6bfe3a49..3eb3fc98 100644 --- a/packages/typescript/ai/tests/stream-processor.test.ts +++ b/packages/typescript/ai/tests/stream-processor.test.ts @@ -35,25 +35,33 @@ async function* streamOf( /** Shorthand for common event sequences. */ const ev = { - runStarted: (runId = 'run-1') => chunk('RUN_STARTED', { runId }), + runStarted: (runId = 'run-1', threadId = 'thread-1') => + chunk('RUN_STARTED', { runId, threadId }), textStart: (messageId = 'msg-1') => chunk('TEXT_MESSAGE_START', { messageId, role: 'assistant' as const }), textContent: (delta: string, messageId = 'msg-1') => chunk('TEXT_MESSAGE_CONTENT', { messageId, delta }), textEnd: (messageId = 'msg-1') => chunk('TEXT_MESSAGE_END', { messageId }), - toolStart: (toolCallId: string, toolName: string, index?: number) => + toolStart: (toolCallId: string, toolCallName: string, index?: number) => chunk('TOOL_CALL_START', { toolCallId, - toolName, + toolCallName, + toolName: toolCallName, ...(index !== undefined ? { index } : {}), }), toolArgs: (toolCallId: string, delta: string) => chunk('TOOL_CALL_ARGS', { toolCallId, delta }), toolEnd: ( toolCallId: string, - toolName: string, + toolCallName: string, opts?: { input?: unknown; result?: string }, - ) => chunk('TOOL_CALL_END', { toolCallId, toolName, ...opts }), + ) => + chunk('TOOL_CALL_END', { + toolCallId, + toolCallName, + toolName: toolCallName, + ...opts, + }), runFinished: ( finishReason: | 'stop' @@ -62,11 +70,12 @@ const ev = { | 'tool_calls' | null = 'stop', runId = 'run-1', - ) => chunk('RUN_FINISHED', { runId, finishReason }), + threadId = 'thread-1', + ) => chunk('RUN_FINISHED', { runId, threadId, finishReason }), runError: (message: string, runId = 'run-1') => - chunk('RUN_ERROR', { runId, error: { message } }), - stepFinished: (delta: string, stepId = 'step-1') => - chunk('STEP_FINISHED', { stepId, delta }), + chunk('RUN_ERROR', { message, runId, error: { message } }), + stepFinished: (delta: string, stepName = 'step-1') => + chunk('STEP_FINISHED', { stepName, stepId: stepName, delta }), custom: (name: string, value?: unknown) => chunk('CUSTOM', { name, value }), } @@ -1365,6 +1374,7 @@ describe('StreamProcessor', () => { processor.processChunk({ type: 'STEP_FINISHED', + stepName: 'step-1', stepId: 'step-1', model: 'test', timestamp: Date.now(), @@ -1513,11 +1523,20 @@ describe('StreamProcessor', () => { processor.prepareAssistantMessage() // These should not create any messages - processor.processChunk(chunk('RUN_STARTED', { runId: 'run-1' })) + processor.processChunk( + chunk('RUN_STARTED', { runId: 'run-1', threadId: 'thread-1' }), + ) processor.processChunk(chunk('TEXT_MESSAGE_END', { messageId: 'msg-1' })) - processor.processChunk(chunk('STEP_STARTED', { stepId: 'step-1' })) - processor.processChunk(chunk('STATE_SNAPSHOT', { state: { key: 'val' } })) - processor.processChunk(chunk('STATE_DELTA', { delta: { key: 'val' } })) + processor.processChunk( + chunk('STEP_STARTED', { stepName: 'step-1', stepId: 'step-1' }), + ) + processor.processChunk( + chunk('STATE_SNAPSHOT', { + snapshot: { key: 'val' }, + state: { key: 'val' }, + }), + ) + processor.processChunk(chunk('STATE_DELTA', { delta: [{ key: 'val' }] })) // No messages created (none of these are content-bearing) expect(processor.getMessages()).toHaveLength(0) @@ -2432,6 +2451,7 @@ describe('StreamProcessor', () => { processor.processChunk({ type: 'TOOL_CALL_START', toolCallId: 'tc-1', + toolCallName: 'myTool', toolName: 'myTool', parentMessageId: 'msg-a', timestamp: Date.now(), @@ -2489,6 +2509,8 @@ describe('StreamProcessor', () => { // RUN_FINISHED fires first — calls finalizeStream which sets isComplete and fires onStreamEnd processor.processChunk({ type: 'RUN_FINISHED', + runId: 'run-1', + threadId: 'thread-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', @@ -2530,6 +2552,7 @@ describe('StreamProcessor', () => { processor.processChunk({ type: 'TOOL_CALL_START', toolCallId: 'tc-old', + toolCallName: 'oldTool', toolName: 'oldTool', parentMessageId: 'msg-old', timestamp: Date.now(), diff --git a/packages/typescript/ai/tests/test-utils.ts b/packages/typescript/ai/tests/test-utils.ts index 380ca3ac..7682c079 100644 --- a/packages/typescript/ai/tests/test-utils.ts +++ b/packages/typescript/ai/tests/test-utils.ts @@ -19,25 +19,33 @@ export function chunk( /** Shorthand chunk factories for common AG-UI events. */ export const ev = { - runStarted: (runId = 'run-1') => chunk('RUN_STARTED', { runId }), + runStarted: (runId = 'run-1', threadId = 'thread-1') => + chunk('RUN_STARTED', { runId, threadId }), textStart: (messageId = 'msg-1') => chunk('TEXT_MESSAGE_START', { messageId, role: 'assistant' as const }), textContent: (delta: string, messageId = 'msg-1') => chunk('TEXT_MESSAGE_CONTENT', { messageId, delta }), textEnd: (messageId = 'msg-1') => chunk('TEXT_MESSAGE_END', { messageId }), - toolStart: (toolCallId: string, toolName: string, index?: number) => + toolStart: (toolCallId: string, toolCallName: string, index?: number) => chunk('TOOL_CALL_START', { toolCallId, - toolName, + toolCallName, + toolName: toolCallName, ...(index !== undefined ? { index } : {}), }), toolArgs: (toolCallId: string, delta: string) => chunk('TOOL_CALL_ARGS', { toolCallId, delta }), toolEnd: ( toolCallId: string, - toolName: string, + toolCallName: string, opts?: { input?: unknown; result?: string }, - ) => chunk('TOOL_CALL_END', { toolCallId, toolName, ...opts }), + ) => + chunk('TOOL_CALL_END', { + toolCallId, + toolCallName, + toolName: toolCallName, + ...opts, + }), runFinished: ( finishReason: | 'stop' @@ -51,12 +59,19 @@ export const ev = { completionTokens: number totalTokens: number }, + threadId = 'thread-1', ) => - chunk('RUN_FINISHED', { runId, finishReason, ...(usage ? { usage } : {}) }), + chunk('RUN_FINISHED', { + runId, + threadId, + finishReason, + ...(usage ? { usage } : {}), + }), runError: (message: string, runId = 'run-1') => - chunk('RUN_ERROR', { runId, error: { message } }), - stepFinished: (delta: string, stepId = 'step-1') => - chunk('STEP_FINISHED', { stepId, delta }), + chunk('RUN_ERROR', { message, runId, error: { message } }), + stepStarted: (stepName = 'step-1') => chunk('STEP_STARTED', { stepName }), + stepFinished: (delta: string, stepName = 'step-1') => + chunk('STEP_FINISHED', { stepName, stepId: stepName, delta }), } // ============================================================================ From 6859631ad8dbddb7cbcb9bbc41fdb6eee3ab09c5 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Tue, 31 Mar 2026 16:50:15 +0200 Subject: [PATCH 07/30] fix(ai): re-add stripToSpec middleware, process raw chunks internally, fix test assertions --- .../typescript/ai/src/activities/chat/index.ts | 16 ++++++++++------ packages/typescript/ai/tests/chat.test.ts | 8 +++++--- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/typescript/ai/src/activities/chat/index.ts b/packages/typescript/ai/src/activities/chat/index.ts index 6f9e294b..3ad3eb71 100644 --- a/packages/typescript/ai/src/activities/chat/index.ts +++ b/packages/typescript/ai/src/activities/chat/index.ts @@ -6,6 +6,7 @@ */ import { devtoolsMiddleware } from '@tanstack/ai-event-client' +import { stripToSpecMiddleware } from '../../strip-to-spec-middleware' import { streamToText } from '../../stream-to-response.js' import { LazyToolManager } from './tools/lazy-tool-manager' import { @@ -318,12 +319,15 @@ class TextEngine< this.threadId = config.params.threadId || this.createId('thread') this.runIdOverride = config.params.runId - // Initialize middleware — devtools middleware is always first - // Note: stripToSpecMiddleware is NOT auto-applied here. The JS consumer - // (StreamProcessor, user code) needs TanStack-specific fields like - // finishReason, delta, content, toolName, etc. Strip-to-spec should be - // applied explicitly when sending events over the wire (SSE/HTTP transport). - const allMiddleware = [devtoolsMiddleware(), ...(config.middleware || [])] + // Initialize middleware — devtools first, strip-to-spec always last. + // handleStreamChunk processes raw chunks BEFORE middleware, so internal + // state management sees extended fields (finishReason, delta, toolName, etc.). + // The strip middleware ensures the yielded public stream is AG-UI spec-compliant. + const allMiddleware = [ + devtoolsMiddleware(), + ...(config.middleware || []), + stripToSpecMiddleware(), + ] this.middlewareRunner = new MiddlewareRunner(allMiddleware) this.middlewareAbortController = new AbortController() this.middlewareCtx = { diff --git a/packages/typescript/ai/tests/chat.test.ts b/packages/typescript/ai/tests/chat.test.ts index 89374348..50fa317f 100644 --- a/packages/typescript/ai/tests/chat.test.ts +++ b/packages/typescript/ai/tests/chat.test.ts @@ -800,7 +800,7 @@ describe('chat()', () => { // RUN_ERROR should be in the chunks const errorChunks = chunks.filter((c) => c.type === 'RUN_ERROR') expect(errorChunks).toHaveLength(1) - expect((errorChunks[0] as any).error.message).toBe('API rate limited') + expect((errorChunks[0] as any).message).toBe('API rate limited') }) it('should not continue the agent loop after RUN_ERROR', async () => { @@ -936,8 +936,10 @@ describe('chat()', () => { const stepChunks = chunks.filter((c) => c.type === 'STEP_FINISHED') expect(stepChunks).toHaveLength(2) - expect((stepChunks[0] as any).delta).toBe('Let me think') - expect((stepChunks[1] as any).delta).toBe(' about this...') + // After strip-to-spec middleware, delta is removed from STEP_FINISHED (internal extension) + // Verify the events pass through with spec fields + expect((stepChunks[0] as any).stepName).toBeDefined() + expect((stepChunks[1] as any).stepName).toBeDefined() }) }) From b705b3a362db66962823cb3d807656a3caf3013c Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Tue, 31 Mar 2026 17:15:53 +0200 Subject: [PATCH 08/30] fix(ai): pipe tool-phase events through middleware, strip toolCallName, fix type errors - Fix EventType enum vs string literal type errors in test files by relaxing chunk helper type params and adding cast helpers - Pipe tool-phase events (TOOL_CALL_END, TOOL_CALL_RESULT, CUSTOM) through the middleware pipeline so strip-to-spec and devtools middleware observe all events, not just model-stream events - Add toolCallName to TOOL_CALL_END strip set in strip-to-spec middleware since AG-UI spec ToolCallEndEvent only has toolCallId - Update test assertions to use TOOL_CALL_RESULT (spec event) instead of checking stripped fields on TOOL_CALL_END --- .../ai/src/activities/chat/index.ts | 41 +++-- .../ai/src/strip-to-spec-middleware.ts | 2 +- packages/typescript/ai/tests/chat.test.ts | 73 ++++---- .../tests/custom-events-integration.test.ts | 36 ++-- .../ai/tests/extend-adapter.test.ts | 4 +- .../typescript/ai/tests/middleware.test.ts | 12 +- .../ai/tests/stream-generation.test.ts | 18 +- .../ai/tests/stream-processor.test.ts | 10 +- .../ai/tests/stream-to-response.test.ts | 64 +++---- .../ai/tests/strip-to-spec-middleware.test.ts | 4 +- packages/typescript/ai/tests/test-utils.ts | 6 +- .../ai/tests/tool-call-manager.test.ts | 172 ++++++++---------- 12 files changed, 229 insertions(+), 213 deletions(-) diff --git a/packages/typescript/ai/src/activities/chat/index.ts b/packages/typescript/ai/src/activities/chat/index.ts index 3ad3eb71..461781df 100644 --- a/packages/typescript/ai/src/activities/chat/index.ts +++ b/packages/typescript/ai/src/activities/chat/index.ts @@ -710,7 +710,7 @@ class TextEngine< undiscoveredLazyResults, finishEvent, )) { - yield chunk + yield* this.pipeThroughMiddleware(chunk) } } @@ -775,7 +775,7 @@ class TextEngine< executionResult.results, finishEvent, )) { - yield chunk + yield* this.pipeThroughMiddleware(chunk) } } @@ -783,14 +783,14 @@ class TextEngine< executionResult.needsApproval, finishEvent, )) { - yield chunk + yield* this.pipeThroughMiddleware(chunk) } for (const chunk of this.buildClientToolChunks( executionResult.needsClientExecution, finishEvent, )) { - yield chunk + yield* this.pipeThroughMiddleware(chunk) } this.setToolPhase('wait') @@ -803,7 +803,7 @@ class TextEngine< ) for (const chunk of toolResultChunks) { - yield chunk + yield* this.pipeThroughMiddleware(chunk) } return 'continue' @@ -850,7 +850,7 @@ class TextEngine< undiscoveredLazyResults, finishEvt, )) { - yield chunk + yield* this.pipeThroughMiddleware(chunk) } } @@ -921,7 +921,7 @@ class TextEngine< executionResult.results, finishEvent, )) { - yield chunk + yield* this.pipeThroughMiddleware(chunk) } } @@ -929,14 +929,14 @@ class TextEngine< executionResult.needsApproval, finishEvent, )) { - yield chunk + yield* this.pipeThroughMiddleware(chunk) } for (const chunk of this.buildClientToolChunks( executionResult.needsClientExecution, finishEvent, )) { - yield chunk + yield* this.pipeThroughMiddleware(chunk) } this.setToolPhase('wait') @@ -949,7 +949,7 @@ class TextEngine< ) for (const chunk of toolResultChunks) { - yield chunk + yield* this.pipeThroughMiddleware(chunk) } // Refresh tools if lazy tools were discovered in this batch @@ -1272,9 +1272,26 @@ class TextEngine< this.toolPhase = phase } + /** + * Pipe a single chunk through the middleware pipeline (strip-to-spec, devtools, etc.) + * and yield all resulting output chunks. + */ + private async *pipeThroughMiddleware( + chunk: StreamChunk, + ): AsyncGenerator { + const outputChunks = await this.middlewareRunner.runOnChunk( + this.middlewareCtx, + chunk, + ) + for (const outputChunk of outputChunks) { + yield outputChunk + this.middlewareCtx.chunkIndex++ + } + } + /** * Drain an executeToolCalls async generator, yielding any CustomEvent chunks - * and returning the final ExecuteToolCallsResult. + * through the middleware pipeline and returning the final ExecuteToolCallsResult. */ private async *drainToolCallGenerator( generator: AsyncGenerator< @@ -1297,7 +1314,7 @@ class TextEngine< > { let next = await generator.next() while (!next.done) { - yield next.value + yield* this.pipeThroughMiddleware(next.value) next = await generator.next() } return next.value diff --git a/packages/typescript/ai/src/strip-to-spec-middleware.ts b/packages/typescript/ai/src/strip-to-spec-middleware.ts index 741c6ca0..026e63e9 100644 --- a/packages/typescript/ai/src/strip-to-spec-middleware.ts +++ b/packages/typescript/ai/src/strip-to-spec-middleware.ts @@ -14,7 +14,7 @@ const STRIP_BY_TYPE: Record> = { TEXT_MESSAGE_CONTENT: new Set(['content']), TOOL_CALL_START: new Set(['toolName', 'index', 'providerMetadata']), TOOL_CALL_ARGS: new Set(['args']), - TOOL_CALL_END: new Set(['toolName', 'input', 'result']), + TOOL_CALL_END: new Set(['toolName', 'toolCallName', 'input', 'result']), RUN_FINISHED: new Set(['finishReason', 'usage']), RUN_ERROR: new Set(['error']), STEP_STARTED: new Set(['stepId', 'stepType']), diff --git a/packages/typescript/ai/tests/chat.test.ts b/packages/typescript/ai/tests/chat.test.ts index 50fa317f..9b67a86c 100644 --- a/packages/typescript/ai/tests/chat.test.ts +++ b/packages/typescript/ai/tests/chat.test.ts @@ -223,11 +223,12 @@ describe('chat()', () => { expect.objectContaining({ toolCallId: 'call_1' }), ) - // A TOOL_CALL_END chunk with result should have been yielded - const toolEndChunks = chunks.filter( - (c) => c.type === 'TOOL_CALL_END' && 'result' in c && c.result, + // A TOOL_CALL_RESULT chunk with content should have been yielded + // (TOOL_CALL_END is also emitted but `result` is stripped by strip-to-spec middleware) + const toolResultChunks = chunks.filter( + (c) => c.type === 'TOOL_CALL_RESULT' && 'content' in c && c.content, ) - expect(toolEndChunks.length).toBeGreaterThanOrEqual(1) + expect(toolResultChunks.length).toBeGreaterThanOrEqual(1) // Adapter was called twice (tool call iteration + final text) expect(calls).toHaveLength(2) @@ -269,14 +270,15 @@ describe('chat()', () => { const chunks = await collectChunks(stream as AsyncIterable) - // Should still complete and yield the error result - const toolEndChunks = chunks.filter( - (c) => c.type === 'TOOL_CALL_END' && 'result' in c, + // Should still complete and yield the error result via TOOL_CALL_RESULT + // (TOOL_CALL_END's `result` is stripped by strip-to-spec middleware) + const toolResultChunks = chunks.filter( + (c) => c.type === 'TOOL_CALL_RESULT' && 'content' in c, ) - expect(toolEndChunks.length).toBeGreaterThanOrEqual(1) - // Error should be in the result - const resultStr = (toolEndChunks[0] as any).result - expect(resultStr).toContain('error') + expect(toolResultChunks.length).toBeGreaterThanOrEqual(1) + // Error should be in the content + const contentStr = (toolResultChunks[0] as any).content + expect(contentStr).toContain('error') }) }) @@ -401,15 +403,15 @@ describe('chat()', () => { // Server tool should have executed expect(searchExecute).toHaveBeenCalledTimes(1) - // TOOL_CALL_END with a result should be emitted for the server tool - const toolEndWithResult = chunks.filter( + // TOOL_CALL_RESULT with content should be emitted for the server tool + // (TOOL_CALL_END is also emitted but `result`/`toolName` are stripped by strip-to-spec middleware) + const toolResultChunks = chunks.filter( (c) => - c.type === 'TOOL_CALL_END' && - (c as any).toolName === 'searchTools' && - 'result' in c && - (c as any).result, + c.type === 'TOOL_CALL_RESULT' && + 'content' in c && + (c as any).content, ) - expect(toolEndWithResult).toHaveLength(1) + expect(toolResultChunks).toHaveLength(1) // Client tool should get a tool-input-available event const customChunks = chunks.filter( @@ -468,15 +470,15 @@ describe('chat()', () => { // Server tool should have executed expect(weatherExecute).toHaveBeenCalledTimes(1) - // TOOL_CALL_END with a result should be emitted for the server tool - const toolEndWithResult = chunks.filter( + // TOOL_CALL_RESULT with content should be emitted for the server tool + // (TOOL_CALL_END is also emitted but `result`/`toolName` are stripped by strip-to-spec middleware) + const toolResultChunks = chunks.filter( (c) => - c.type === 'TOOL_CALL_END' && - (c as any).toolName === 'getWeather' && - 'result' in c && - (c as any).result, + c.type === 'TOOL_CALL_RESULT' && + 'content' in c && + (c as any).content, ) - expect(toolEndWithResult).toHaveLength(1) + expect(toolResultChunks).toHaveLength(1) // Client tool should get a tool-input-available event const customChunks = chunks.filter( @@ -601,11 +603,12 @@ describe('chat()', () => { // Tool should have been executed as pending expect(executeSpy).toHaveBeenCalledTimes(1) - // TOOL_CALL_END with result should be in the stream - const toolEndChunks = chunks.filter( - (c) => c.type === 'TOOL_CALL_END' && 'result' in c && c.result, + // TOOL_CALL_RESULT with content should be in the stream + // (TOOL_CALL_END's `result` is stripped by strip-to-spec middleware) + const toolResultChunks = chunks.filter( + (c) => c.type === 'TOOL_CALL_RESULT' && 'content' in c && c.content, ) - expect(toolEndChunks.length).toBeGreaterThanOrEqual(1) + expect(toolResultChunks.length).toBeGreaterThanOrEqual(1) // Adapter should have been called with the tool result in messages expect(calls).toHaveLength(1) @@ -1249,14 +1252,14 @@ describe('chat()', () => { const chunks = await collectChunks(stream as AsyncIterable) // The first tool call result should contain a "must be discovered first" error - const toolEndChunks = chunks.filter( - (c) => c.type === 'TOOL_CALL_END', + // TOOL_CALL_RESULT carries the content (TOOL_CALL_END's result is stripped by middleware) + const toolResultChunks = chunks.filter( + (c) => c.type === 'TOOL_CALL_RESULT', ) as Array - const errorResult = toolEndChunks.find( + const errorResult = toolResultChunks.find( (c: any) => - c.toolName === 'getWeather' && - c.result && - c.result.includes('must be discovered first'), + c.content && + c.content.includes('must be discovered first'), ) expect(errorResult).toBeDefined() diff --git a/packages/typescript/ai/tests/custom-events-integration.test.ts b/packages/typescript/ai/tests/custom-events-integration.test.ts index 9fe31fb6..73137293 100644 --- a/packages/typescript/ai/tests/custom-events-integration.test.ts +++ b/packages/typescript/ai/tests/custom-events-integration.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it, vi } from 'vitest' import { toolDefinition } from '../src/activities/chat/tools/tool-definition' import { StreamProcessor } from '../src/activities/chat/stream/processor' +import type { StreamChunk } from '../src/types' import { z } from 'zod' +/** Cast a plain object to StreamChunk for test convenience. */ +const sc = (obj: Record) => obj as unknown as StreamChunk + describe('Custom Events Integration', () => { it('should emit custom events from tool execution context', async () => { const onCustomEvent = vi.fn() @@ -61,28 +65,28 @@ describe('Custom Events Integration', () => { processor.prepareAssistantMessage() // Simulate tool call sequence - processor.processChunk({ + processor.processChunk(sc({ type: 'TOOL_CALL_START', toolCallId: 'tc-1', toolName: 'testTool', timestamp: Date.now(), index: 0, - }) + })) - processor.processChunk({ + processor.processChunk(sc({ type: 'TOOL_CALL_ARGS', toolCallId: 'tc-1', timestamp: Date.now(), delta: '{"message": "Hello World"}', - }) + })) - processor.processChunk({ + processor.processChunk(sc({ type: 'TOOL_CALL_END', toolCallId: 'tc-1', toolName: 'testTool', timestamp: Date.now(), input: { message: 'Hello World' }, - }) + })) // Execute the tool manually (simulating what happens in real scenario) const toolExecuteFunc = (testTool as any).execute @@ -91,12 +95,12 @@ describe('Custom Events Integration', () => { toolCallId: 'tc-1', emitCustomEvent: (eventName: string, data: any) => { // This simulates the real behavior where emitCustomEvent creates CUSTOM stream chunks - processor.processChunk({ + processor.processChunk(sc({ type: 'CUSTOM', name: eventName, value: { ...data, toolCallId: 'tc-1' }, timestamp: Date.now(), - }) + })) }, } @@ -157,12 +161,12 @@ describe('Custom Events Integration', () => { }) // Emit custom event without toolCallId - processor.processChunk({ + processor.processChunk(sc({ type: 'CUSTOM', name: 'system:status', value: { status: 'ready', version: '1.0.0' }, timestamp: Date.now(), - }) + })) expect(onCustomEvent).toHaveBeenCalledWith( 'system:status', @@ -194,7 +198,7 @@ describe('Custom Events Integration', () => { processor.prepareAssistantMessage() // System event: tool-input-available - processor.processChunk({ + processor.processChunk(sc({ type: 'CUSTOM', name: 'tool-input-available', value: { @@ -203,10 +207,10 @@ describe('Custom Events Integration', () => { input: { test: true }, }, timestamp: Date.now(), - }) + })) // System event: approval-requested - processor.processChunk({ + processor.processChunk(sc({ type: 'CUSTOM', name: 'approval-requested', value: { @@ -216,15 +220,15 @@ describe('Custom Events Integration', () => { approval: { id: 'approval-1', needsApproval: true }, }, timestamp: Date.now(), - }) + })) // Custom event (should be forwarded) - processor.processChunk({ + processor.processChunk(sc({ type: 'CUSTOM', name: 'user:custom-event', value: { message: 'This should be forwarded' }, timestamp: Date.now(), - }) + })) // System events should trigger their specific handlers, not onCustomEvent expect(onToolCall).toHaveBeenCalledTimes(1) diff --git a/packages/typescript/ai/tests/extend-adapter.test.ts b/packages/typescript/ai/tests/extend-adapter.test.ts index daad73b4..e919c8b5 100644 --- a/packages/typescript/ai/tests/extend-adapter.test.ts +++ b/packages/typescript/ai/tests/extend-adapter.test.ts @@ -97,14 +97,14 @@ class MockTextAdapter extends BaseTextAdapter< delta: 'Hello', content: 'Hello', model: this.model, - } + } as unknown as StreamChunk yield { type: 'RUN_FINISHED', runId: 'mock-id', timestamp: Date.now(), finishReason: 'stop', model: this.model, - } + } as unknown as StreamChunk } /* eslint-enable @typescript-eslint/require-await */ diff --git a/packages/typescript/ai/tests/middleware.test.ts b/packages/typescript/ai/tests/middleware.test.ts index ec78ebb7..7e68caf6 100644 --- a/packages/typescript/ai/tests/middleware.test.ts +++ b/packages/typescript/ai/tests/middleware.test.ts @@ -1348,6 +1348,9 @@ describe('chat() middleware', () => { // Tool execution phase 'onBeforeToolCall:myTool', 'onAfterToolCall:myTool:true', + // Tool result events (piped through middleware) + 'onChunk:TOOL_CALL_END', + 'onChunk:TOOL_CALL_RESULT', // Second model call (beforeModel phase) 'onConfig:beforeModel', 'onChunk:RUN_STARTED', @@ -1418,11 +1421,16 @@ describe('chat() middleware', () => { .map((e) => e.phase) expect(configPhases).toEqual(['init', 'beforeModel', 'beforeModel']) - // onChunk should be in 'modelStream' phase + // onChunk phases: model stream chunks are 'modelStream', tool result chunks are 'afterTools' const chunkPhases = phaseLog .filter((e) => e.hook === 'onChunk') .map((e) => e.phase) - expect(chunkPhases.every((p) => p === 'modelStream')).toBe(true) + // All chunk phases should be either 'modelStream' or 'afterTools' + expect(chunkPhases.every((p) => p === 'modelStream' || p === 'afterTools')).toBe(true) + // At least one should be 'modelStream' (from the adapter stream) + expect(chunkPhases.filter((p) => p === 'modelStream').length).toBeGreaterThan(0) + // Tool result chunks should be in 'afterTools' phase + expect(chunkPhases.filter((p) => p === 'afterTools').length).toBeGreaterThan(0) // onBeforeToolCall should be in 'beforeTools' phase const beforeToolPhases = phaseLog diff --git a/packages/typescript/ai/tests/stream-generation.test.ts b/packages/typescript/ai/tests/stream-generation.test.ts index c3ae9a0d..8cb554cf 100644 --- a/packages/typescript/ai/tests/stream-generation.test.ts +++ b/packages/typescript/ai/tests/stream-generation.test.ts @@ -96,7 +96,7 @@ describe('generateImage({ stream: true })', () => { expect(chunks[1]!.type).toBe('RUN_ERROR') if (chunks[1]!.type === 'RUN_ERROR') { - expect(chunks[1]!.error.message).toBe('Image generation failed') + expect(chunks[1]!.error!.message).toBe('Image generation failed') } }) @@ -266,7 +266,7 @@ describe('generateVideo({ stream: true })', () => { const error = chunks.find((c) => c.type === 'RUN_ERROR') if (error?.type === 'RUN_ERROR') { - expect(error.error.message).toBe('Video processing error') + expect(error.error!.message).toBe('Video processing error') } }) @@ -310,7 +310,7 @@ describe('generateVideo({ stream: true })', () => { const error = chunks.find((c) => c.type === 'RUN_ERROR') expect(error).toBeDefined() if (error?.type === 'RUN_ERROR') { - expect(error.error.message).toBe('Video generation timed out') + expect(error.error!.message).toBe('Video generation timed out') } }) @@ -338,7 +338,7 @@ describe('generateVideo({ stream: true })', () => { const error = chunks.find((c) => c.type === 'RUN_ERROR') if (error?.type === 'RUN_ERROR') { - expect(error.error.message).toBe('Job creation failed') + expect(error.error!.message).toBe('Job creation failed') } }) @@ -360,7 +360,7 @@ describe('generateVideo({ stream: true })', () => { const error = chunks.find((c) => c.type === 'RUN_ERROR') expect(error).toBeDefined() if (error?.type === 'RUN_ERROR') { - expect(error.error.message).toBe('Failed to retrieve video URL') + expect(error.error!.message).toBe('Failed to retrieve video URL') } }) @@ -384,7 +384,7 @@ describe('generateVideo({ stream: true })', () => { const error = chunks.find((c) => c.type === 'RUN_ERROR') expect(error).toBeDefined() if (error?.type === 'RUN_ERROR') { - expect(error.error.message).toBe('Content policy violation') + expect(error.error!.message).toBe('Content policy violation') } }) @@ -407,7 +407,7 @@ describe('generateVideo({ stream: true })', () => { const error = chunks.find((c) => c.type === 'RUN_ERROR') expect(error).toBeDefined() if (error?.type === 'RUN_ERROR') { - expect(error.error.message).toBe('Video generation failed') + expect(error.error!.message).toBe('Video generation failed') } }) }) @@ -496,7 +496,7 @@ describe('generateSpeech({ stream: true })', () => { expect(chunks[1]!.type).toBe('RUN_ERROR') if (chunks[1]!.type === 'RUN_ERROR') { - expect(chunks[1]!.error.message).toBe('Speech generation failed') + expect(chunks[1]!.error!.message).toBe('Speech generation failed') } }) }) @@ -583,7 +583,7 @@ describe('generateTranscription({ stream: true })', () => { expect(chunks[1]!.type).toBe('RUN_ERROR') if (chunks[1]!.type === 'RUN_ERROR') { - expect(chunks[1]!.error.message).toBe('Transcription failed') + expect(chunks[1]!.error!.message).toBe('Transcription failed') } }) }) diff --git a/packages/typescript/ai/tests/stream-processor.test.ts b/packages/typescript/ai/tests/stream-processor.test.ts index 3eb3fc98..2bcb56e0 100644 --- a/packages/typescript/ai/tests/stream-processor.test.ts +++ b/packages/typescript/ai/tests/stream-processor.test.ts @@ -17,9 +17,9 @@ import type { // ============================================================================ /** Create a typed StreamChunk with minimal boilerplate. */ -function chunk( - type: T, - fields: Omit, 'type' | 'timestamp'>, +function chunk( + type: string, + fields: Record = {}, ): StreamChunk { return { type, timestamp: Date.now(), ...fields } as unknown as StreamChunk } @@ -2426,7 +2426,7 @@ describe('StreamProcessor', () => { }, ], timestamp: Date.now(), - } as StreamChunk) + } as unknown as StreamChunk) const messages = processor.getMessages() expect(messages).toHaveLength(1) @@ -2570,7 +2570,7 @@ describe('StreamProcessor', () => { }, ], timestamp: Date.now(), - } as StreamChunk) + } as unknown as StreamChunk) // Verify old messages are replaced const messagesAfterSnapshot = processor.getMessages() diff --git a/packages/typescript/ai/tests/stream-to-response.test.ts b/packages/typescript/ai/tests/stream-to-response.test.ts index dc2dbe30..10978053 100644 --- a/packages/typescript/ai/tests/stream-to-response.test.ts +++ b/packages/typescript/ai/tests/stream-to-response.test.ts @@ -7,10 +7,10 @@ import type { StreamChunk } from '../src/types' // Helper to create mock async iterable async function* createMockStream( - chunks: Array, + chunks: Array>, ): AsyncGenerator { for (const chunk of chunks) { - yield chunk + yield chunk as StreamChunk } } @@ -35,7 +35,7 @@ async function readStream(stream: ReadableStream): Promise { describe('toServerSentEventsStream', () => { it('should convert chunks to SSE format', async () => { - const chunks: Array = [ + const chunks: Array> = [ { type: 'TEXT_MESSAGE_CONTENT', messageId: 'msg-1', @@ -65,7 +65,7 @@ describe('toServerSentEventsStream', () => { }) it('should format each chunk with data: prefix', async () => { - const chunks: Array = [ + const chunks: Array> = [ { type: 'TEXT_MESSAGE_CONTENT', messageId: 'msg-1', @@ -86,7 +86,7 @@ describe('toServerSentEventsStream', () => { }) it('should end with [DONE] marker', async () => { - const chunks: Array = [ + const chunks: Array> = [ { type: 'TEXT_MESSAGE_CONTENT', messageId: 'msg-1', @@ -109,7 +109,7 @@ describe('toServerSentEventsStream', () => { }) it('should handle tool call events', async () => { - const chunks: Array = [ + const chunks: Array> = [ { type: 'TOOL_CALL_START', toolCallId: 'call-1', @@ -130,7 +130,7 @@ describe('toServerSentEventsStream', () => { }) it('should handle RUN_FINISHED events', async () => { - const chunks: Array = [ + const chunks: Array> = [ { type: 'RUN_FINISHED', runId: 'run-1', @@ -150,7 +150,7 @@ describe('toServerSentEventsStream', () => { }) it('should handle RUN_ERROR events', async () => { - const chunks: Array = [ + const chunks: Array> = [ { type: 'RUN_ERROR', runId: 'run-1', @@ -178,7 +178,7 @@ describe('toServerSentEventsStream', () => { it('should abort when abortController signals abort', async () => { const abortController = new AbortController() - const chunks: Array = [ + const chunks: Array> = [ { type: 'TEXT_MESSAGE_CONTENT', messageId: 'msg-1', @@ -210,7 +210,7 @@ describe('toServerSentEventsStream', () => { timestamp: Date.now(), delta: 'Test', content: 'Test', - } + } as unknown as StreamChunk throw new Error('Stream error') } @@ -224,7 +224,7 @@ describe('toServerSentEventsStream', () => { it('should not send error if aborted', async () => { const abortController = new AbortController() - async function* errorStream(): AsyncGenerator { + async function* errorStream(): AsyncGenerator { abortController.abort() throw new Error('Stream error') } @@ -240,7 +240,7 @@ describe('toServerSentEventsStream', () => { const abortController = new AbortController() const abortSpy = vi.spyOn(abortController, 'abort') - const chunks: Array = [ + const chunks: Array> = [ { type: 'TEXT_MESSAGE_CONTENT', messageId: 'msg-1', @@ -261,7 +261,7 @@ describe('toServerSentEventsStream', () => { }) it('should handle multiple chunks correctly', async () => { - const chunks: Array = [ + const chunks: Array> = [ { type: 'TEXT_MESSAGE_CONTENT', messageId: 'msg-1', @@ -301,7 +301,7 @@ describe('toServerSentEventsStream', () => { describe('toServerSentEventsResponse', () => { it('should create Response with SSE headers', async () => { - const chunks: Array = [ + const chunks: Array> = [ { type: 'TEXT_MESSAGE_CONTENT', messageId: 'msg-1', @@ -322,7 +322,7 @@ describe('toServerSentEventsResponse', () => { }) it('should allow custom headers', async () => { - const chunks: Array = [] + const chunks: Array> = [] const stream = createMockStream(chunks) const response = toServerSentEventsResponse(stream, { headers: { @@ -335,7 +335,7 @@ describe('toServerSentEventsResponse', () => { }) it('should merge custom headers with SSE headers', async () => { - const chunks: Array = [] + const chunks: Array> = [] const stream = createMockStream(chunks) const response = toServerSentEventsResponse(stream, { headers: { @@ -351,7 +351,7 @@ describe('toServerSentEventsResponse', () => { it('should handle abortController in options', async () => { const abortController = new AbortController() - const chunks: Array = [ + const chunks: Array> = [ { type: 'TEXT_MESSAGE_CONTENT', messageId: 'msg-1', @@ -379,7 +379,7 @@ describe('toServerSentEventsResponse', () => { }) it('should handle status and statusText', async () => { - const chunks: Array = [] + const chunks: Array> = [] const stream = createMockStream(chunks) const response = toServerSentEventsResponse(stream, { status: 201, @@ -391,7 +391,7 @@ describe('toServerSentEventsResponse', () => { }) it('should stream chunks correctly through Response', async () => { - const chunks: Array = [ + const chunks: Array> = [ { type: 'TEXT_MESSAGE_CONTENT', messageId: 'msg-1', @@ -427,7 +427,7 @@ describe('toServerSentEventsResponse', () => { }) it('should handle undefined init parameter', async () => { - const chunks: Array = [] + const chunks: Array> = [] const stream = createMockStream(chunks) const response = toServerSentEventsResponse(stream, undefined) @@ -436,7 +436,7 @@ describe('toServerSentEventsResponse', () => { }) it('should handle empty init object', async () => { - const chunks: Array = [] + const chunks: Array> = [] const stream = createMockStream(chunks) const response = toServerSentEventsResponse(stream, {}) @@ -457,10 +457,10 @@ describe('SSE Round-Trip (Encode → Decode)', () => { */ async function parseSSEStream( sseStream: ReadableStream, - ): Promise> { + ): Promise>> { const reader = sseStream.getReader() const decoder = new TextDecoder() - const chunks: Array = [] + const chunks: Array> = [] let buffer = '' try { @@ -492,7 +492,7 @@ describe('SSE Round-Trip (Encode → Decode)', () => { } it('should preserve TEXT_MESSAGE_CONTENT events', async () => { - const originalChunks: Array = [ + const originalChunks: Array> = [ { type: 'TEXT_MESSAGE_CONTENT', messageId: 'msg-1', @@ -528,7 +528,7 @@ describe('SSE Round-Trip (Encode → Decode)', () => { }) it('should preserve TOOL_CALL_* events', async () => { - const originalChunks: Array = [ + const originalChunks: Array> = [ { type: 'TOOL_CALL_START', toolCallId: 'tc-1', @@ -575,7 +575,7 @@ describe('SSE Round-Trip (Encode → Decode)', () => { }) it('should preserve RUN_* events', async () => { - const originalChunks: Array = [ + const originalChunks: Array> = [ { type: 'RUN_STARTED', runId: 'run-1', @@ -604,7 +604,7 @@ describe('SSE Round-Trip (Encode → Decode)', () => { }) it('should preserve RUN_ERROR events', async () => { - const originalChunks: Array = [ + const originalChunks: Array> = [ { type: 'RUN_ERROR', runId: 'run-1', @@ -626,7 +626,7 @@ describe('SSE Round-Trip (Encode → Decode)', () => { }) it('should preserve STEP_FINISHED events (thinking)', async () => { - const originalChunks: Array = [ + const originalChunks: Array> = [ { type: 'STEP_STARTED', stepId: 'step-1', @@ -656,7 +656,7 @@ describe('SSE Round-Trip (Encode → Decode)', () => { }) it('should preserve CUSTOM events', async () => { - const originalChunks: Array = [ + const originalChunks: Array> = [ { type: 'CUSTOM', model: 'test', @@ -700,7 +700,7 @@ describe('SSE Round-Trip (Encode → Decode)', () => { }) it('should preserve TEXT_MESSAGE_START/END events', async () => { - const originalChunks: Array = [ + const originalChunks: Array> = [ { type: 'TEXT_MESSAGE_START', messageId: 'msg-1', @@ -733,7 +733,7 @@ describe('SSE Round-Trip (Encode → Decode)', () => { }) it('should preserve complex mixed event sequence', async () => { - const originalChunks: Array = [ + const originalChunks: Array> = [ { type: 'RUN_STARTED', runId: 'run-1', @@ -826,7 +826,7 @@ describe('SSE Round-Trip (Encode → Decode)', () => { }) it('should preserve unicode and special characters', async () => { - const originalChunks: Array = [ + const originalChunks: Array> = [ { type: 'TEXT_MESSAGE_CONTENT', messageId: 'msg-1', diff --git a/packages/typescript/ai/tests/strip-to-spec-middleware.test.ts b/packages/typescript/ai/tests/strip-to-spec-middleware.test.ts index 8397d60c..2f560e48 100644 --- a/packages/typescript/ai/tests/strip-to-spec-middleware.test.ts +++ b/packages/typescript/ai/tests/strip-to-spec-middleware.test.ts @@ -6,7 +6,7 @@ import type { StreamChunk } from '../src/types' * Helper to create a StreamChunk with the given type and fields. */ function makeChunk( - type: StreamChunk['type'], + type: string, fields: Record, ): StreamChunk { return { type, timestamp: Date.now(), ...fields } as unknown as StreamChunk @@ -90,12 +90,14 @@ describe('stripToSpec', () => { const chunk = makeChunk('TOOL_CALL_END', { toolCallId: 'tc-1', toolName: 'getTodos', + toolCallName: 'getTodos', input: { userId: '123' }, result: '[{"id":"1","title":"Buy milk"}]', model: 'gpt-4o', }) const result = stripToSpec(chunk) as Record expect(result).not.toHaveProperty('toolName') + expect(result).not.toHaveProperty('toolCallName') expect(result).not.toHaveProperty('input') expect(result).not.toHaveProperty('result') expect(result).not.toHaveProperty('model') diff --git a/packages/typescript/ai/tests/test-utils.ts b/packages/typescript/ai/tests/test-utils.ts index 7682c079..879db40f 100644 --- a/packages/typescript/ai/tests/test-utils.ts +++ b/packages/typescript/ai/tests/test-utils.ts @@ -6,9 +6,9 @@ import type { StreamChunk, TextMessageContentEvent, Tool } from '../src/types' // ============================================================================ /** Create a typed StreamChunk with minimal boilerplate. */ -export function chunk( - type: T, - fields: Omit, 'type' | 'timestamp'>, +export function chunk( + type: string, + fields: Record = {}, ): StreamChunk { return { type, timestamp: Date.now(), ...fields } as unknown as StreamChunk } diff --git a/packages/typescript/ai/tests/tool-call-manager.test.ts b/packages/typescript/ai/tests/tool-call-manager.test.ts index 546fc7a9..782e41f7 100644 --- a/packages/typescript/ai/tests/tool-call-manager.test.ts +++ b/packages/typescript/ai/tests/tool-call-manager.test.ts @@ -4,16 +4,44 @@ import { ToolCallManager, executeToolCalls, } from '../src/activities/chat/tools/tool-calls' -import type { RunFinishedEvent, Tool, ToolCall } from '../src/types' +import type { + RunFinishedEvent, + Tool, + ToolCall, + ToolCallStartEvent, + ToolCallArgsEvent, + ToolCallEndEvent, +} from '../src/types' + +/** Helper to create a ToolCallStartEvent from plain fields (avoids EventType enum issues). */ +function toolCallStart( + fields: Omit, +): ToolCallStartEvent { + return { type: 'TOOL_CALL_START' as any, timestamp: Date.now(), ...fields } as ToolCallStartEvent +} + +/** Helper to create a ToolCallArgsEvent from plain fields. */ +function toolCallArgs( + fields: Omit, +): ToolCallArgsEvent { + return { type: 'TOOL_CALL_ARGS' as any, timestamp: Date.now(), ...fields } as ToolCallArgsEvent +} + +/** Helper to create a ToolCallEndEvent from plain fields. */ +function toolCallEnd( + fields: Omit, +): ToolCallEndEvent { + return { type: 'TOOL_CALL_END' as any, timestamp: Date.now(), ...fields } as ToolCallEndEvent +} describe('ToolCallManager', () => { - const mockFinishedEvent: RunFinishedEvent = { + const mockFinishedEvent = { type: 'RUN_FINISHED', runId: 'test-run-id', model: 'gpt-4', timestamp: Date.now(), finishReason: 'tool_calls', - } + } as unknown as RunFinishedEvent const mockWeatherTool: Tool = { name: 'get_weather', @@ -44,27 +72,21 @@ describe('ToolCallManager', () => { it('should accumulate tool call events', () => { const manager = new ToolCallManager([mockWeatherTool]) - manager.addToolCallStartEvent({ - type: 'TOOL_CALL_START', + manager.addToolCallStartEvent(toolCallStart({ toolCallId: 'call_123', toolName: 'get_weather', - timestamp: Date.now(), index: 0, - }) + })) - manager.addToolCallArgsEvent({ - type: 'TOOL_CALL_ARGS', + manager.addToolCallArgsEvent(toolCallArgs({ toolCallId: 'call_123', - timestamp: Date.now(), delta: '{"loc', - }) + })) - manager.addToolCallArgsEvent({ - type: 'TOOL_CALL_ARGS', + manager.addToolCallArgsEvent(toolCallArgs({ toolCallId: 'call_123', - timestamp: Date.now(), delta: 'ation":"Paris"}', - }) + })) const toolCalls = manager.getToolCalls() expect(toolCalls).toHaveLength(1) @@ -77,29 +99,23 @@ describe('ToolCallManager', () => { const manager = new ToolCallManager([mockWeatherTool]) // Add complete tool call - manager.addToolCallStartEvent({ - type: 'TOOL_CALL_START', + manager.addToolCallStartEvent(toolCallStart({ toolCallId: 'call_123', toolName: 'get_weather', - timestamp: Date.now(), index: 0, - }) + })) - manager.addToolCallArgsEvent({ - type: 'TOOL_CALL_ARGS', + manager.addToolCallArgsEvent(toolCallArgs({ toolCallId: 'call_123', - timestamp: Date.now(), delta: '{}', - }) + })) // Add incomplete tool call (no name - empty toolName) - manager.addToolCallStartEvent({ - type: 'TOOL_CALL_START', + manager.addToolCallStartEvent(toolCallStart({ toolCallId: 'call_456', toolName: '', - timestamp: Date.now(), index: 1, - }) + })) const toolCalls = manager.getToolCalls() expect(toolCalls).toHaveLength(1) @@ -109,20 +125,16 @@ describe('ToolCallManager', () => { it('should execute tools and emit TOOL_CALL_END events', async () => { const manager = new ToolCallManager([mockWeatherTool]) - manager.addToolCallStartEvent({ - type: 'TOOL_CALL_START', + manager.addToolCallStartEvent(toolCallStart({ toolCallId: 'call_123', toolName: 'get_weather', - timestamp: Date.now(), index: 0, - }) + })) - manager.addToolCallArgsEvent({ - type: 'TOOL_CALL_ARGS', + manager.addToolCallArgsEvent(toolCallArgs({ toolCallId: 'call_123', - timestamp: Date.now(), delta: '{"location":"Paris"}', - }) + })) const { chunks: emittedChunks, result: finalResult } = await collectGeneratorOutput(manager.executeTools(mockFinishedEvent)) @@ -154,20 +166,16 @@ describe('ToolCallManager', () => { const manager = new ToolCallManager([errorTool]) - manager.addToolCallStartEvent({ - type: 'TOOL_CALL_START', + manager.addToolCallStartEvent(toolCallStart({ toolCallId: 'call_123', toolName: 'error_tool', - timestamp: Date.now(), index: 0, - }) + })) - manager.addToolCallArgsEvent({ - type: 'TOOL_CALL_ARGS', + manager.addToolCallArgsEvent(toolCallArgs({ toolCallId: 'call_123', - timestamp: Date.now(), delta: '{}', - }) + })) // Properly consume the generator const { chunks, result: toolResults } = await collectGeneratorOutput( @@ -194,20 +202,16 @@ describe('ToolCallManager', () => { const manager = new ToolCallManager([noExecuteTool]) - manager.addToolCallStartEvent({ - type: 'TOOL_CALL_START', + manager.addToolCallStartEvent(toolCallStart({ toolCallId: 'call_123', toolName: 'no_execute', - timestamp: Date.now(), index: 0, - }) + })) - manager.addToolCallArgsEvent({ - type: 'TOOL_CALL_ARGS', + manager.addToolCallArgsEvent(toolCallArgs({ toolCallId: 'call_123', - timestamp: Date.now(), delta: '{}', - }) + })) const { chunks, result: toolResults } = await collectGeneratorOutput( manager.executeTools(mockFinishedEvent), @@ -223,13 +227,11 @@ describe('ToolCallManager', () => { it('should clear tool calls', () => { const manager = new ToolCallManager([mockWeatherTool]) - manager.addToolCallStartEvent({ - type: 'TOOL_CALL_START', + manager.addToolCallStartEvent(toolCallStart({ toolCallId: 'call_123', toolName: 'get_weather', - timestamp: Date.now(), index: 0, - }) + })) expect(manager.hasToolCalls()).toBe(true) @@ -254,35 +256,27 @@ describe('ToolCallManager', () => { const manager = new ToolCallManager([mockWeatherTool, calculateTool]) // Add two different tool calls - manager.addToolCallStartEvent({ - type: 'TOOL_CALL_START', + manager.addToolCallStartEvent(toolCallStart({ toolCallId: 'call_weather', toolName: 'get_weather', - timestamp: Date.now(), index: 0, - }) + })) - manager.addToolCallArgsEvent({ - type: 'TOOL_CALL_ARGS', + manager.addToolCallArgsEvent(toolCallArgs({ toolCallId: 'call_weather', - timestamp: Date.now(), delta: '{"location":"Paris"}', - }) + })) - manager.addToolCallStartEvent({ - type: 'TOOL_CALL_START', + manager.addToolCallStartEvent(toolCallStart({ toolCallId: 'call_calc', toolName: 'calculate', - timestamp: Date.now(), index: 1, - }) + })) - manager.addToolCallArgsEvent({ - type: 'TOOL_CALL_ARGS', + manager.addToolCallArgsEvent(toolCallArgs({ toolCallId: 'call_calc', - timestamp: Date.now(), delta: '{"expression":"5+3"}', - }) + })) const toolCalls = manager.getToolCalls() expect(toolCalls).toHaveLength(2) @@ -306,13 +300,11 @@ describe('ToolCallManager', () => { it('should handle TOOL_CALL_START events', () => { const manager = new ToolCallManager([mockWeatherTool]) - manager.addToolCallStartEvent({ - type: 'TOOL_CALL_START', + manager.addToolCallStartEvent(toolCallStart({ toolCallId: 'call_123', toolName: 'get_weather', - timestamp: Date.now(), index: 0, - }) + })) const toolCalls = manager.getToolCalls() expect(toolCalls).toHaveLength(1) @@ -324,27 +316,21 @@ describe('ToolCallManager', () => { it('should accumulate TOOL_CALL_ARGS events', () => { const manager = new ToolCallManager([mockWeatherTool]) - manager.addToolCallStartEvent({ - type: 'TOOL_CALL_START', + manager.addToolCallStartEvent(toolCallStart({ toolCallId: 'call_123', toolName: 'get_weather', - timestamp: Date.now(), index: 0, - }) + })) - manager.addToolCallArgsEvent({ - type: 'TOOL_CALL_ARGS', + manager.addToolCallArgsEvent(toolCallArgs({ toolCallId: 'call_123', - timestamp: Date.now(), delta: '{"loc', - }) + })) - manager.addToolCallArgsEvent({ - type: 'TOOL_CALL_ARGS', + manager.addToolCallArgsEvent(toolCallArgs({ toolCallId: 'call_123', - timestamp: Date.now(), delta: 'ation":"Paris"}', - }) + })) const toolCalls = manager.getToolCalls() expect(toolCalls).toHaveLength(1) @@ -354,21 +340,17 @@ describe('ToolCallManager', () => { it('should complete tool calls with TOOL_CALL_END events', () => { const manager = new ToolCallManager([mockWeatherTool]) - manager.addToolCallStartEvent({ - type: 'TOOL_CALL_START', + manager.addToolCallStartEvent(toolCallStart({ toolCallId: 'call_123', toolName: 'get_weather', - timestamp: Date.now(), index: 0, - }) + })) - manager.completeToolCall({ - type: 'TOOL_CALL_END', + manager.completeToolCall(toolCallEnd({ toolCallId: 'call_123', toolName: 'get_weather', - timestamp: Date.now(), input: { location: 'New York' }, - }) + })) const toolCalls = manager.getToolCalls() expect(toolCalls).toHaveLength(1) From 3e1d948fe7325f7a0a2dbbc4963bfff61593afa1 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Tue, 31 Mar 2026 17:17:11 +0200 Subject: [PATCH 09/30] style: format test files --- .../typescript/ai/tests/middleware.test.ts | 12 +- .../ai/tests/strip-to-spec-middleware.test.ts | 5 +- .../ai/tests/tool-call-manager.test.ts | 280 +++++++++++------- 3 files changed, 179 insertions(+), 118 deletions(-) diff --git a/packages/typescript/ai/tests/middleware.test.ts b/packages/typescript/ai/tests/middleware.test.ts index 7e68caf6..a3d98ad8 100644 --- a/packages/typescript/ai/tests/middleware.test.ts +++ b/packages/typescript/ai/tests/middleware.test.ts @@ -1426,11 +1426,17 @@ describe('chat() middleware', () => { .filter((e) => e.hook === 'onChunk') .map((e) => e.phase) // All chunk phases should be either 'modelStream' or 'afterTools' - expect(chunkPhases.every((p) => p === 'modelStream' || p === 'afterTools')).toBe(true) + expect( + chunkPhases.every((p) => p === 'modelStream' || p === 'afterTools'), + ).toBe(true) // At least one should be 'modelStream' (from the adapter stream) - expect(chunkPhases.filter((p) => p === 'modelStream').length).toBeGreaterThan(0) + expect( + chunkPhases.filter((p) => p === 'modelStream').length, + ).toBeGreaterThan(0) // Tool result chunks should be in 'afterTools' phase - expect(chunkPhases.filter((p) => p === 'afterTools').length).toBeGreaterThan(0) + expect( + chunkPhases.filter((p) => p === 'afterTools').length, + ).toBeGreaterThan(0) // onBeforeToolCall should be in 'beforeTools' phase const beforeToolPhases = phaseLog diff --git a/packages/typescript/ai/tests/strip-to-spec-middleware.test.ts b/packages/typescript/ai/tests/strip-to-spec-middleware.test.ts index 2f560e48..8a5f8fb2 100644 --- a/packages/typescript/ai/tests/strip-to-spec-middleware.test.ts +++ b/packages/typescript/ai/tests/strip-to-spec-middleware.test.ts @@ -5,10 +5,7 @@ import type { StreamChunk } from '../src/types' /** * Helper to create a StreamChunk with the given type and fields. */ -function makeChunk( - type: string, - fields: Record, -): StreamChunk { +function makeChunk(type: string, fields: Record): StreamChunk { return { type, timestamp: Date.now(), ...fields } as unknown as StreamChunk } diff --git a/packages/typescript/ai/tests/tool-call-manager.test.ts b/packages/typescript/ai/tests/tool-call-manager.test.ts index 782e41f7..c36eb45c 100644 --- a/packages/typescript/ai/tests/tool-call-manager.test.ts +++ b/packages/typescript/ai/tests/tool-call-manager.test.ts @@ -17,21 +17,33 @@ import type { function toolCallStart( fields: Omit, ): ToolCallStartEvent { - return { type: 'TOOL_CALL_START' as any, timestamp: Date.now(), ...fields } as ToolCallStartEvent + return { + type: 'TOOL_CALL_START' as any, + timestamp: Date.now(), + ...fields, + } as ToolCallStartEvent } /** Helper to create a ToolCallArgsEvent from plain fields. */ function toolCallArgs( fields: Omit, ): ToolCallArgsEvent { - return { type: 'TOOL_CALL_ARGS' as any, timestamp: Date.now(), ...fields } as ToolCallArgsEvent + return { + type: 'TOOL_CALL_ARGS' as any, + timestamp: Date.now(), + ...fields, + } as ToolCallArgsEvent } /** Helper to create a ToolCallEndEvent from plain fields. */ function toolCallEnd( fields: Omit, ): ToolCallEndEvent { - return { type: 'TOOL_CALL_END' as any, timestamp: Date.now(), ...fields } as ToolCallEndEvent + return { + type: 'TOOL_CALL_END' as any, + timestamp: Date.now(), + ...fields, + } as ToolCallEndEvent } describe('ToolCallManager', () => { @@ -72,21 +84,27 @@ describe('ToolCallManager', () => { it('should accumulate tool call events', () => { const manager = new ToolCallManager([mockWeatherTool]) - manager.addToolCallStartEvent(toolCallStart({ - toolCallId: 'call_123', - toolName: 'get_weather', - index: 0, - })) + manager.addToolCallStartEvent( + toolCallStart({ + toolCallId: 'call_123', + toolName: 'get_weather', + index: 0, + }), + ) - manager.addToolCallArgsEvent(toolCallArgs({ - toolCallId: 'call_123', - delta: '{"loc', - })) + manager.addToolCallArgsEvent( + toolCallArgs({ + toolCallId: 'call_123', + delta: '{"loc', + }), + ) - manager.addToolCallArgsEvent(toolCallArgs({ - toolCallId: 'call_123', - delta: 'ation":"Paris"}', - })) + manager.addToolCallArgsEvent( + toolCallArgs({ + toolCallId: 'call_123', + delta: 'ation":"Paris"}', + }), + ) const toolCalls = manager.getToolCalls() expect(toolCalls).toHaveLength(1) @@ -99,23 +117,29 @@ describe('ToolCallManager', () => { const manager = new ToolCallManager([mockWeatherTool]) // Add complete tool call - manager.addToolCallStartEvent(toolCallStart({ - toolCallId: 'call_123', - toolName: 'get_weather', - index: 0, - })) + manager.addToolCallStartEvent( + toolCallStart({ + toolCallId: 'call_123', + toolName: 'get_weather', + index: 0, + }), + ) - manager.addToolCallArgsEvent(toolCallArgs({ - toolCallId: 'call_123', - delta: '{}', - })) + manager.addToolCallArgsEvent( + toolCallArgs({ + toolCallId: 'call_123', + delta: '{}', + }), + ) // Add incomplete tool call (no name - empty toolName) - manager.addToolCallStartEvent(toolCallStart({ - toolCallId: 'call_456', - toolName: '', - index: 1, - })) + manager.addToolCallStartEvent( + toolCallStart({ + toolCallId: 'call_456', + toolName: '', + index: 1, + }), + ) const toolCalls = manager.getToolCalls() expect(toolCalls).toHaveLength(1) @@ -125,16 +149,20 @@ describe('ToolCallManager', () => { it('should execute tools and emit TOOL_CALL_END events', async () => { const manager = new ToolCallManager([mockWeatherTool]) - manager.addToolCallStartEvent(toolCallStart({ - toolCallId: 'call_123', - toolName: 'get_weather', - index: 0, - })) + manager.addToolCallStartEvent( + toolCallStart({ + toolCallId: 'call_123', + toolName: 'get_weather', + index: 0, + }), + ) - manager.addToolCallArgsEvent(toolCallArgs({ - toolCallId: 'call_123', - delta: '{"location":"Paris"}', - })) + manager.addToolCallArgsEvent( + toolCallArgs({ + toolCallId: 'call_123', + delta: '{"location":"Paris"}', + }), + ) const { chunks: emittedChunks, result: finalResult } = await collectGeneratorOutput(manager.executeTools(mockFinishedEvent)) @@ -166,16 +194,20 @@ describe('ToolCallManager', () => { const manager = new ToolCallManager([errorTool]) - manager.addToolCallStartEvent(toolCallStart({ - toolCallId: 'call_123', - toolName: 'error_tool', - index: 0, - })) + manager.addToolCallStartEvent( + toolCallStart({ + toolCallId: 'call_123', + toolName: 'error_tool', + index: 0, + }), + ) - manager.addToolCallArgsEvent(toolCallArgs({ - toolCallId: 'call_123', - delta: '{}', - })) + manager.addToolCallArgsEvent( + toolCallArgs({ + toolCallId: 'call_123', + delta: '{}', + }), + ) // Properly consume the generator const { chunks, result: toolResults } = await collectGeneratorOutput( @@ -202,16 +234,20 @@ describe('ToolCallManager', () => { const manager = new ToolCallManager([noExecuteTool]) - manager.addToolCallStartEvent(toolCallStart({ - toolCallId: 'call_123', - toolName: 'no_execute', - index: 0, - })) + manager.addToolCallStartEvent( + toolCallStart({ + toolCallId: 'call_123', + toolName: 'no_execute', + index: 0, + }), + ) - manager.addToolCallArgsEvent(toolCallArgs({ - toolCallId: 'call_123', - delta: '{}', - })) + manager.addToolCallArgsEvent( + toolCallArgs({ + toolCallId: 'call_123', + delta: '{}', + }), + ) const { chunks, result: toolResults } = await collectGeneratorOutput( manager.executeTools(mockFinishedEvent), @@ -227,11 +263,13 @@ describe('ToolCallManager', () => { it('should clear tool calls', () => { const manager = new ToolCallManager([mockWeatherTool]) - manager.addToolCallStartEvent(toolCallStart({ - toolCallId: 'call_123', - toolName: 'get_weather', - index: 0, - })) + manager.addToolCallStartEvent( + toolCallStart({ + toolCallId: 'call_123', + toolName: 'get_weather', + index: 0, + }), + ) expect(manager.hasToolCalls()).toBe(true) @@ -256,27 +294,35 @@ describe('ToolCallManager', () => { const manager = new ToolCallManager([mockWeatherTool, calculateTool]) // Add two different tool calls - manager.addToolCallStartEvent(toolCallStart({ - toolCallId: 'call_weather', - toolName: 'get_weather', - index: 0, - })) - - manager.addToolCallArgsEvent(toolCallArgs({ - toolCallId: 'call_weather', - delta: '{"location":"Paris"}', - })) - - manager.addToolCallStartEvent(toolCallStart({ - toolCallId: 'call_calc', - toolName: 'calculate', - index: 1, - })) - - manager.addToolCallArgsEvent(toolCallArgs({ - toolCallId: 'call_calc', - delta: '{"expression":"5+3"}', - })) + manager.addToolCallStartEvent( + toolCallStart({ + toolCallId: 'call_weather', + toolName: 'get_weather', + index: 0, + }), + ) + + manager.addToolCallArgsEvent( + toolCallArgs({ + toolCallId: 'call_weather', + delta: '{"location":"Paris"}', + }), + ) + + manager.addToolCallStartEvent( + toolCallStart({ + toolCallId: 'call_calc', + toolName: 'calculate', + index: 1, + }), + ) + + manager.addToolCallArgsEvent( + toolCallArgs({ + toolCallId: 'call_calc', + delta: '{"expression":"5+3"}', + }), + ) const toolCalls = manager.getToolCalls() expect(toolCalls).toHaveLength(2) @@ -300,11 +346,13 @@ describe('ToolCallManager', () => { it('should handle TOOL_CALL_START events', () => { const manager = new ToolCallManager([mockWeatherTool]) - manager.addToolCallStartEvent(toolCallStart({ - toolCallId: 'call_123', - toolName: 'get_weather', - index: 0, - })) + manager.addToolCallStartEvent( + toolCallStart({ + toolCallId: 'call_123', + toolName: 'get_weather', + index: 0, + }), + ) const toolCalls = manager.getToolCalls() expect(toolCalls).toHaveLength(1) @@ -316,21 +364,27 @@ describe('ToolCallManager', () => { it('should accumulate TOOL_CALL_ARGS events', () => { const manager = new ToolCallManager([mockWeatherTool]) - manager.addToolCallStartEvent(toolCallStart({ - toolCallId: 'call_123', - toolName: 'get_weather', - index: 0, - })) + manager.addToolCallStartEvent( + toolCallStart({ + toolCallId: 'call_123', + toolName: 'get_weather', + index: 0, + }), + ) - manager.addToolCallArgsEvent(toolCallArgs({ - toolCallId: 'call_123', - delta: '{"loc', - })) + manager.addToolCallArgsEvent( + toolCallArgs({ + toolCallId: 'call_123', + delta: '{"loc', + }), + ) - manager.addToolCallArgsEvent(toolCallArgs({ - toolCallId: 'call_123', - delta: 'ation":"Paris"}', - })) + manager.addToolCallArgsEvent( + toolCallArgs({ + toolCallId: 'call_123', + delta: 'ation":"Paris"}', + }), + ) const toolCalls = manager.getToolCalls() expect(toolCalls).toHaveLength(1) @@ -340,17 +394,21 @@ describe('ToolCallManager', () => { it('should complete tool calls with TOOL_CALL_END events', () => { const manager = new ToolCallManager([mockWeatherTool]) - manager.addToolCallStartEvent(toolCallStart({ - toolCallId: 'call_123', - toolName: 'get_weather', - index: 0, - })) + manager.addToolCallStartEvent( + toolCallStart({ + toolCallId: 'call_123', + toolName: 'get_weather', + index: 0, + }), + ) - manager.completeToolCall(toolCallEnd({ - toolCallId: 'call_123', - toolName: 'get_weather', - input: { location: 'New York' }, - })) + manager.completeToolCall( + toolCallEnd({ + toolCallId: 'call_123', + toolName: 'get_weather', + input: { location: 'New York' }, + }), + ) const toolCalls = manager.getToolCalls() expect(toolCalls).toHaveLength(1) From 41dbb1931cbe9a5b99954623f6b8ac63d367d9a0 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 1 Apr 2026 11:32:46 +0200 Subject: [PATCH 10/30] test(ai): add tests for REASONING events, TOOL_CALL_RESULT, threadId, and strip compliance --- packages/typescript/ai/tests/chat.test.ts | 182 +++++++++++++++++- .../ai/tests/stream-processor.test.ts | 174 +++++++++++++++++ 2 files changed, 347 insertions(+), 9 deletions(-) diff --git a/packages/typescript/ai/tests/chat.test.ts b/packages/typescript/ai/tests/chat.test.ts index 9b67a86c..d9d4bdbf 100644 --- a/packages/typescript/ai/tests/chat.test.ts +++ b/packages/typescript/ai/tests/chat.test.ts @@ -407,9 +407,7 @@ describe('chat()', () => { // (TOOL_CALL_END is also emitted but `result`/`toolName` are stripped by strip-to-spec middleware) const toolResultChunks = chunks.filter( (c) => - c.type === 'TOOL_CALL_RESULT' && - 'content' in c && - (c as any).content, + c.type === 'TOOL_CALL_RESULT' && 'content' in c && (c as any).content, ) expect(toolResultChunks).toHaveLength(1) @@ -474,9 +472,7 @@ describe('chat()', () => { // (TOOL_CALL_END is also emitted but `result`/`toolName` are stripped by strip-to-spec middleware) const toolResultChunks = chunks.filter( (c) => - c.type === 'TOOL_CALL_RESULT' && - 'content' in c && - (c as any).content, + c.type === 'TOOL_CALL_RESULT' && 'content' in c && (c as any).content, ) expect(toolResultChunks).toHaveLength(1) @@ -1257,9 +1253,7 @@ describe('chat()', () => { (c) => c.type === 'TOOL_CALL_RESULT', ) as Array const errorResult = toolResultChunks.find( - (c: any) => - c.content && - c.content.includes('must be discovered first'), + (c: any) => c.content && c.content.includes('must be discovered first'), ) expect(errorResult).toBeDefined() @@ -1290,4 +1284,174 @@ describe('chat()', () => { expect(toolNames).toContain('normalTool') }) }) + + // ========================================================================== + // AG-UI spec compliance (threadId, strip middleware) + // ========================================================================== + describe('AG-UI spec compliance', () => { + it('should include threadId on RUN_STARTED and RUN_FINISHED events', async () => { + const { adapter } = createMockAdapter({ + iterations: [ + [ + ev.runStarted(), + ev.textStart(), + ev.textContent('Hi'), + ev.textEnd(), + ev.runFinished('stop'), + ], + ], + }) + + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'Hello' }], + threadId: 'my-thread-id', + }) + + const chunks = await collectChunks(stream as AsyncIterable) + + const runStarted = chunks.find((c) => c.type === 'RUN_STARTED') + expect(runStarted).toBeDefined() + expect((runStarted as any).threadId).toBeDefined() + + const runFinished = chunks.find((c) => c.type === 'RUN_FINISHED') + expect(runFinished).toBeDefined() + expect((runFinished as any).threadId).toBeDefined() + }) + + it('should strip model field from yielded events', async () => { + const { adapter } = createMockAdapter({ + iterations: [ + [ + ev.runStarted(), + ev.textStart(), + ev.textContent('Hi'), + ev.textEnd(), + ev.runFinished('stop'), + ], + ], + }) + + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'Hello' }], + }) + + const chunks = await collectChunks(stream as AsyncIterable) + + // No yielded event should have the model field (it's stripped) + for (const chunk of chunks) { + expect('model' in chunk).toBe(false) + } + }) + + it('should strip toolName from TOOL_CALL_START events (only toolCallName)', async () => { + const { adapter } = createMockAdapter({ + iterations: [ + [ + ev.runStarted(), + ev.textStart(), + ev.toolStart('tc-1', 'get_weather'), + ev.toolArgs('tc-1', '{}'), + ev.toolEnd('tc-1', 'get_weather', { + input: {}, + result: '{}', + }), + ev.runFinished('tool_calls'), + ], + [ + ev.runStarted(), + ev.textStart(), + ev.textContent('Done'), + ev.textEnd(), + ev.runFinished('stop'), + ], + ], + }) + + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'Weather' }], + tools: [serverTool('get_weather', () => ({ temp: 72 }))], + }) + + const chunks = await collectChunks(stream as AsyncIterable) + + const toolStartChunks = chunks.filter( + (c) => c.type === 'TOOL_CALL_START', + ) + for (const chunk of toolStartChunks) { + // toolCallName should be present (spec field) + expect((chunk as any).toolCallName).toBe('get_weather') + // toolName should be stripped + expect('toolName' in chunk).toBe(false) + } + }) + + it('should strip finishReason and usage from RUN_FINISHED events', async () => { + const { adapter } = createMockAdapter({ + iterations: [ + [ + ev.runStarted(), + ev.textStart(), + ev.textContent('Hi'), + ev.textEnd(), + ev.runFinished('stop'), + ], + ], + }) + + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'Hello' }], + }) + + const chunks = await collectChunks(stream as AsyncIterable) + + const runFinished = chunks.find((c) => c.type === 'RUN_FINISHED') + expect(runFinished).toBeDefined() + // These internal extension fields should be stripped + expect('finishReason' in runFinished!).toBe(false) + expect('usage' in runFinished!).toBe(false) + }) + + it('should emit TOOL_CALL_RESULT events during agent loop', async () => { + const { adapter } = createMockAdapter({ + iterations: [ + [ + ev.runStarted(), + ev.textStart(), + ev.toolStart('tc-1', 'get_weather'), + ev.toolArgs('tc-1', '{}'), + ev.toolEnd('tc-1', 'get_weather', { input: {} }), + ev.runFinished('tool_calls'), + ], + [ + ev.runStarted(), + ev.textStart(), + ev.textContent('72F'), + ev.textEnd(), + ev.runFinished('stop'), + ], + ], + }) + + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'Weather?' }], + tools: [serverTool('get_weather', () => ({ temp: 72 }))], + }) + + const chunks = await collectChunks(stream as AsyncIterable) + + const resultChunks = chunks.filter( + (c) => c.type === 'TOOL_CALL_RESULT', + ) + expect(resultChunks.length).toBeGreaterThanOrEqual(1) + expect((resultChunks[0] as any).toolCallId).toBe('tc-1') + expect((resultChunks[0] as any).content).toContain('72') + // model should be stripped + expect('model' in resultChunks[0]!).toBe(false) + }) + }) }) diff --git a/packages/typescript/ai/tests/stream-processor.test.ts b/packages/typescript/ai/tests/stream-processor.test.ts index 2bcb56e0..9b680358 100644 --- a/packages/typescript/ai/tests/stream-processor.test.ts +++ b/packages/typescript/ai/tests/stream-processor.test.ts @@ -2932,4 +2932,178 @@ describe('StreamProcessor', () => { expect(events.onStreamStart).not.toHaveBeenCalled() }) }) + + // ========================================================================== + // REASONING events + // ========================================================================== + describe('REASONING events', () => { + it('should accumulate reasoning content from REASONING_MESSAGE_CONTENT', () => { + const events = spyEvents() + const processor = new StreamProcessor({ events }) + + processor.processChunk(ev.runStarted()) + processor.processChunk(ev.textStart()) + processor.processChunk(chunk('REASONING_START', { messageId: 'r-1' })) + processor.processChunk( + chunk('REASONING_MESSAGE_START', { + messageId: 'r-1', + role: 'reasoning', + }), + ) + processor.processChunk( + chunk('REASONING_MESSAGE_CONTENT', { + messageId: 'r-1', + delta: 'Let me think', + }), + ) + processor.processChunk( + chunk('REASONING_MESSAGE_CONTENT', { + messageId: 'r-1', + delta: ' about this...', + }), + ) + processor.processChunk( + chunk('REASONING_MESSAGE_END', { messageId: 'r-1' }), + ) + processor.processChunk(chunk('REASONING_END', { messageId: 'r-1' })) + + // Should fire onThinkingUpdate with accumulated content + expect(events.onThinkingUpdate).toHaveBeenCalledTimes(2) + expect(events.onThinkingUpdate).toHaveBeenNthCalledWith( + 1, + expect.any(String), + 'Let me think', + ) + expect(events.onThinkingUpdate).toHaveBeenNthCalledWith( + 2, + expect.any(String), + 'Let me think about this...', + ) + }) + + it('should create a thinking part in the UIMessage', () => { + const events = spyEvents() + const processor = new StreamProcessor({ events }) + + processor.processChunk(ev.runStarted()) + processor.processChunk(ev.textStart()) + processor.processChunk(chunk('REASONING_START', { messageId: 'r-1' })) + processor.processChunk( + chunk('REASONING_MESSAGE_START', { + messageId: 'r-1', + role: 'reasoning', + }), + ) + processor.processChunk( + chunk('REASONING_MESSAGE_CONTENT', { + messageId: 'r-1', + delta: 'Thinking...', + }), + ) + processor.processChunk( + chunk('REASONING_MESSAGE_END', { messageId: 'r-1' }), + ) + processor.processChunk(chunk('REASONING_END', { messageId: 'r-1' })) + processor.processChunk(ev.textContent('Answer')) + processor.processChunk(ev.textEnd()) + processor.processChunk(ev.runFinished()) + + const messages = processor.getMessages() + const assistantMsg = messages.find((m) => m.role === 'assistant') + expect(assistantMsg).toBeDefined() + + const thinkingPart = assistantMsg!.parts.find( + (p) => p.type === 'thinking', + ) + expect(thinkingPart).toBeDefined() + expect(thinkingPart!.content).toBe('Thinking...') + + const textPart = assistantMsg!.parts.find((p) => p.type === 'text') + expect(textPart).toBeDefined() + expect(textPart!.content).toBe('Answer') + }) + + it('should handle REASONING events without errors when no matching message', () => { + const events = spyEvents() + const processor = new StreamProcessor({ events }) + + // REASONING events before TEXT_MESSAGE_START — should not crash + processor.processChunk(ev.runStarted()) + processor.processChunk(chunk('REASONING_START', { messageId: 'r-1' })) + processor.processChunk( + chunk('REASONING_MESSAGE_START', { + messageId: 'r-1', + role: 'reasoning', + }), + ) + processor.processChunk( + chunk('REASONING_MESSAGE_CONTENT', { + messageId: 'r-1', + delta: 'thinking', + }), + ) + processor.processChunk( + chunk('REASONING_MESSAGE_END', { messageId: 'r-1' }), + ) + processor.processChunk(chunk('REASONING_END', { messageId: 'r-1' })) + + // Should not throw, onThinkingUpdate should still fire since + // ensureAssistantMessage creates one + expect(events.onThinkingUpdate).toHaveBeenCalled() + }) + + it('should not fail on no-op REASONING lifecycle events', () => { + const processor = new StreamProcessor() + + processor.processChunk(ev.runStarted()) + processor.processChunk(ev.textStart()) + + // These are no-ops but should not throw + processor.processChunk(chunk('REASONING_START', { messageId: 'r-1' })) + processor.processChunk( + chunk('REASONING_MESSAGE_START', { + messageId: 'r-1', + role: 'reasoning', + }), + ) + processor.processChunk( + chunk('REASONING_MESSAGE_END', { messageId: 'r-1' }), + ) + processor.processChunk(chunk('REASONING_END', { messageId: 'r-1' })) + + // No crash = success + expect(processor.getMessages()).toBeDefined() + }) + }) + + // ========================================================================== + // TOOL_CALL_RESULT event + // ========================================================================== + describe('TOOL_CALL_RESULT event', () => { + it('should process TOOL_CALL_RESULT without errors', () => { + const events = spyEvents() + const processor = new StreamProcessor({ events }) + + processor.processChunk(ev.runStarted()) + processor.processChunk(ev.textStart()) + processor.processChunk(ev.toolStart('tc-1', 'get_weather')) + processor.processChunk( + ev.toolEnd('tc-1', 'get_weather', { + input: { city: 'NYC' }, + }), + ) + processor.processChunk( + chunk('TOOL_CALL_RESULT', { + messageId: 'tool-result-1', + toolCallId: 'tc-1', + content: '{"temp": 72}', + role: 'tool', + }), + ) + processor.processChunk(ev.runFinished('tool_calls')) + + // TOOL_CALL_RESULT is a no-op in StreamProcessor, but should not throw + expect(processor.getMessages()).toBeDefined() + }) + }) }) From ebad94f652fe649028c79d1805d780ec8eb6c155 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 1 Apr 2026 12:04:13 +0200 Subject: [PATCH 11/30] fix: resolve eslint, type, and test failures across all packages - Fix 5 ESLint errors in @tanstack/ai (array-type, no-unnecessary-condition, no-unnecessary-type-assertion, sort-imports) - Fix ESLint error in @tanstack/ai-event-client (no-unnecessary-condition) - Fix string literal vs EventType enum type errors across all 7 adapter packages by adding asChunk helper that casts event objects to StreamChunk - Fix @tanstack/ai-client source type errors (chunk.error possibly undefined, runId access on RUN_ERROR events, connection-adapters push calls) - Fix @tanstack/ai-client and @tanstack/ai-openrouter test type errors - Fix tool-call-manager tests to use toolCallName instead of deprecated toolName --- .../ai-anthropic/src/adapters/summarize.ts | 12 +- .../ai-anthropic/src/adapters/text.ts | 105 +++++++------ .../typescript/ai-client/src/chat-client.ts | 5 +- .../ai-client/src/connection-adapters.ts | 4 +- .../ai-client/src/generation-client.ts | 2 +- .../ai-client/src/video-generation-client.ts | 2 +- .../ai-client/tests/chat-client-abort.test.ts | 44 +++--- .../ai-client/tests/chat-client.test.ts | 94 ++++++------ .../tests/connection-adapters.test.ts | 30 ++-- .../ai-client/tests/generation-client.test.ts | 90 ++++++----- .../tests/video-generation-client.test.ts | 114 +++++++------- .../src/devtools-middleware.ts | 2 +- .../ai-gemini/src/adapters/summarize.ts | 12 +- .../typescript/ai-gemini/src/adapters/text.ts | 97 ++++++------ .../typescript/ai-grok/src/adapters/text.ts | 49 +++--- .../typescript/ai-groq/src/adapters/text.ts | 49 +++--- .../ai-ollama/src/adapters/summarize.ts | 12 +- .../typescript/ai-ollama/src/adapters/text.ts | 101 ++++++------ .../typescript/ai-openai/src/adapters/text.ts | 145 +++++++++--------- .../ai-openrouter/src/adapters/summarize.ts | 2 +- .../ai-openrouter/src/adapters/text.ts | 113 +++++++------- .../tests/openrouter-adapter.test.ts | 12 +- .../src/activities/chat/stream/processor.ts | 7 +- .../src/activities/chat/tools/tool-calls.ts | 3 +- packages/typescript/ai/src/types.ts | 34 ++-- packages/typescript/ai/tests/chat.test.ts | 8 +- .../tests/custom-events-integration.test.ts | 138 +++++++++-------- .../ai/tests/tool-call-manager.test.ts | 26 ++-- 28 files changed, 699 insertions(+), 613 deletions(-) diff --git a/packages/typescript/ai-anthropic/src/adapters/summarize.ts b/packages/typescript/ai-anthropic/src/adapters/summarize.ts index 958c8661..bc88721b 100644 --- a/packages/typescript/ai-anthropic/src/adapters/summarize.ts +++ b/packages/typescript/ai-anthropic/src/adapters/summarize.ts @@ -12,6 +12,10 @@ import type { } from '@tanstack/ai' import type { AnthropicClientConfig } from '../utils' +/** Cast an event object to StreamChunk. */ +const asChunk = (chunk: Record) => + chunk as unknown as StreamChunk + /** * Configuration for Anthropic summarize adapter */ @@ -103,18 +107,18 @@ export class AnthropicSummarizeAdapter< if (event.delta.type === 'text_delta') { const delta = event.delta.text accumulatedContent += delta - yield { + yield asChunk({ type: 'TEXT_MESSAGE_CONTENT', messageId: id, model, timestamp: Date.now(), delta, content: accumulatedContent, - } + }) } } else if (event.type === 'message_delta') { outputTokens = event.usage.output_tokens - yield { + yield asChunk({ type: 'RUN_FINISHED', runId: id, model, @@ -129,7 +133,7 @@ export class AnthropicSummarizeAdapter< completionTokens: outputTokens, totalTokens: inputTokens + outputTokens, }, - } + }) } } } diff --git a/packages/typescript/ai-anthropic/src/adapters/text.ts b/packages/typescript/ai-anthropic/src/adapters/text.ts index 6f2a524c..401ac4b0 100644 --- a/packages/typescript/ai-anthropic/src/adapters/text.ts +++ b/packages/typescript/ai-anthropic/src/adapters/text.ts @@ -45,6 +45,11 @@ import type { } from '../message-types' import type { AnthropicClientConfig } from '../utils' +/** Cast an event object to StreamChunk. Adapters construct events with string + * literal types which are structurally compatible with the EventType enum. */ +const asChunk = (chunk: Record) => + chunk as unknown as StreamChunk + /** * Configuration for Anthropic text adapter */ @@ -134,7 +139,7 @@ export class AnthropicTextAdapter< ) } catch (error: unknown) { const err = error as Error & { status?: number; code?: string } - yield { + yield asChunk({ type: 'RUN_ERROR', model: options.model, timestamp: Date.now(), @@ -144,7 +149,7 @@ export class AnthropicTextAdapter< message: err.message || 'Unknown error occurred', code: err.code || String(err.status), }, - } + }) } } @@ -556,13 +561,13 @@ export class AnthropicTextAdapter< // Emit RUN_STARTED on first event if (!hasEmittedRunStarted) { hasEmittedRunStarted = true - yield { + yield asChunk({ type: 'RUN_STARTED', runId, threadId, model, timestamp, - } + }) } if (event.type === 'content_block_start') { @@ -582,86 +587,86 @@ export class AnthropicTextAdapter< reasoningMessageId = genId() // Spec REASONING events - yield { + yield asChunk({ type: 'REASONING_START', messageId: reasoningMessageId, model, timestamp, - } - yield { + }) + yield asChunk({ type: 'REASONING_MESSAGE_START', messageId: reasoningMessageId, role: 'reasoning' as const, model, timestamp, - } + }) // Legacy STEP events (kept during transition) - yield { + yield asChunk({ type: 'STEP_STARTED', stepName: stepId, stepId, model, timestamp, stepType: 'thinking', - } + }) } } else if (event.type === 'content_block_delta') { if (event.delta.type === 'text_delta') { // Close reasoning before text starts if (reasoningMessageId && !hasClosedReasoning) { hasClosedReasoning = true - yield { + yield asChunk({ type: 'REASONING_MESSAGE_END', messageId: reasoningMessageId, model, timestamp, - } - yield { + }) + yield asChunk({ type: 'REASONING_END', messageId: reasoningMessageId, model, timestamp, - } + }) } // Emit TEXT_MESSAGE_START on first text content if (!hasEmittedTextMessageStart) { hasEmittedTextMessageStart = true - yield { + yield asChunk({ type: 'TEXT_MESSAGE_START', messageId, model, timestamp, role: 'assistant', - } + }) } const delta = event.delta.text accumulatedContent += delta - yield { + yield asChunk({ type: 'TEXT_MESSAGE_CONTENT', messageId, model, timestamp, delta, content: accumulatedContent, - } + }) } else if (event.delta.type === 'thinking_delta') { const delta = event.delta.thinking accumulatedThinking += delta // Spec REASONING content event - yield { + yield asChunk({ type: 'REASONING_MESSAGE_CONTENT', messageId: reasoningMessageId!, delta, model, timestamp, - } + }) // Legacy STEP event - yield { + yield asChunk({ type: 'STEP_FINISHED', stepName: stepId || genId(), stepId: stepId || genId(), @@ -669,14 +674,14 @@ export class AnthropicTextAdapter< timestamp, delta, content: accumulatedThinking, - } + }) } else if (event.delta.type === 'input_json_delta') { const existing = toolCallsMap.get(currentToolIndex) if (existing) { // Emit TOOL_CALL_START on first args delta if (!existing.started) { existing.started = true - yield { + yield asChunk({ type: 'TOOL_CALL_START', toolCallId: existing.id, toolCallName: existing.name, @@ -684,19 +689,19 @@ export class AnthropicTextAdapter< model, timestamp, index: currentToolIndex, - } + }) } existing.input += event.delta.partial_json - yield { + yield asChunk({ type: 'TOOL_CALL_ARGS', toolCallId: existing.id, model, timestamp, delta: event.delta.partial_json, args: existing.input, - } + }) } } } else if (event.type === 'content_block_stop') { @@ -706,7 +711,7 @@ export class AnthropicTextAdapter< // If tool call wasn't started yet (no args), start it now if (!existing.started) { existing.started = true - yield { + yield asChunk({ type: 'TOOL_CALL_START', toolCallId: existing.id, toolCallName: existing.name, @@ -714,7 +719,7 @@ export class AnthropicTextAdapter< model, timestamp, index: currentToolIndex, - } + }) } // Emit TOOL_CALL_END @@ -726,7 +731,7 @@ export class AnthropicTextAdapter< parsedInput = {} } - yield { + yield asChunk({ type: 'TOOL_CALL_END', toolCallId: existing.id, toolCallName: existing.name, @@ -734,7 +739,7 @@ export class AnthropicTextAdapter< model, timestamp, input: parsedInput, - } + }) // Reset so a new TEXT_MESSAGE_START is emitted if text follows tool calls hasEmittedTextMessageStart = false @@ -742,12 +747,12 @@ export class AnthropicTextAdapter< } else { // Emit TEXT_MESSAGE_END only for text blocks (not tool_use blocks) if (hasEmittedTextMessageStart && accumulatedContent) { - yield { + yield asChunk({ type: 'TEXT_MESSAGE_END', messageId, model, timestamp, - } + }) } } currentBlockType = null @@ -755,32 +760,32 @@ export class AnthropicTextAdapter< // Close reasoning events if still open if (reasoningMessageId && !hasClosedReasoning) { hasClosedReasoning = true - yield { + yield asChunk({ type: 'REASONING_MESSAGE_END', messageId: reasoningMessageId, model, timestamp, - } - yield { + }) + yield asChunk({ type: 'REASONING_END', messageId: reasoningMessageId, model, timestamp, - } + }) } // Only emit RUN_FINISHED from message_stop if message_delta didn't already emit one. // message_delta carries the real stop_reason (tool_use, end_turn, etc.), // while message_stop is just a completion signal. if (!hasEmittedRunFinished) { - yield { + yield asChunk({ type: 'RUN_FINISHED', runId, threadId, model, timestamp, finishReason: 'stop', - } + }) } } else if (event.type === 'message_delta') { if (event.delta.stop_reason) { @@ -789,23 +794,23 @@ export class AnthropicTextAdapter< // Close reasoning events if still open if (reasoningMessageId && !hasClosedReasoning) { hasClosedReasoning = true - yield { + yield asChunk({ type: 'REASONING_MESSAGE_END', messageId: reasoningMessageId, model, timestamp, - } - yield { + }) + yield asChunk({ type: 'REASONING_END', messageId: reasoningMessageId, model, timestamp, - } + }) } switch (event.delta.stop_reason) { case 'tool_use': { - yield { + yield asChunk({ type: 'RUN_FINISHED', runId, threadId, @@ -819,11 +824,11 @@ export class AnthropicTextAdapter< (event.usage.input_tokens || 0) + (event.usage.output_tokens || 0), }, - } + }) break } case 'max_tokens': { - yield { + yield asChunk({ type: 'RUN_ERROR', runId, model, @@ -836,11 +841,11 @@ export class AnthropicTextAdapter< 'The response was cut off because the maximum token limit was reached.', code: 'max_tokens', }, - } + }) break } default: { - yield { + yield asChunk({ type: 'RUN_FINISHED', runId, threadId, @@ -854,7 +859,7 @@ export class AnthropicTextAdapter< (event.usage.input_tokens || 0) + (event.usage.output_tokens || 0), }, - } + }) } } } @@ -863,7 +868,7 @@ export class AnthropicTextAdapter< } catch (error: unknown) { const err = error as Error & { status?: number; code?: string } - yield { + yield asChunk({ type: 'RUN_ERROR', runId, model, @@ -874,7 +879,7 @@ export class AnthropicTextAdapter< message: err.message || 'Unknown error occurred', code: err.code || String(err.status), }, - } + }) } } } diff --git a/packages/typescript/ai-client/src/chat-client.ts b/packages/typescript/ai-client/src/chat-client.ts index 7272394a..dca6b334 100644 --- a/packages/typescript/ai-client/src/chat-client.ts +++ b/packages/typescript/ai-client/src/chat-client.ts @@ -396,8 +396,9 @@ export class ChatClient { // RUN_FINISHED / RUN_ERROR signal run completion — resolve processing // (redundant if onStreamEnd already resolved it, harmless) if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') { - if (chunk.runId) { - this.activeRunIds.delete(chunk.runId) + const runId = chunk.type === 'RUN_FINISHED' ? chunk.runId : undefined + if (runId) { + this.activeRunIds.delete(runId) } else if (chunk.type === 'RUN_ERROR') { // RUN_ERROR without runId is a session-level error; clear all runs this.activeRunIds.clear() diff --git a/packages/typescript/ai-client/src/connection-adapters.ts b/packages/typescript/ai-client/src/connection-adapters.ts index 182bddbb..b5de861e 100644 --- a/packages/typescript/ai-client/src/connection-adapters.ts +++ b/packages/typescript/ai-client/src/connection-adapters.ts @@ -193,7 +193,7 @@ export function normalizeConnectionAdapter( model: 'connect-wrapper', timestamp: Date.now(), finishReason: 'stop', - }) + } as unknown as StreamChunk) } } catch (err) { if (!abortSignal?.aborted && !hasTerminalEvent) { @@ -206,7 +206,7 @@ export function normalizeConnectionAdapter( ? err.message : 'Unknown error in connect()', }, - }) + } as unknown as StreamChunk) } throw err } diff --git a/packages/typescript/ai-client/src/generation-client.ts b/packages/typescript/ai-client/src/generation-client.ts index 144a67fc..2b4b1a07 100644 --- a/packages/typescript/ai-client/src/generation-client.ts +++ b/packages/typescript/ai-client/src/generation-client.ts @@ -177,7 +177,7 @@ export class GenerationClient< break } case 'RUN_ERROR': { - throw new Error(chunk.error.message) + throw new Error(chunk.error?.message ?? chunk.message) } } } diff --git a/packages/typescript/ai-client/src/video-generation-client.ts b/packages/typescript/ai-client/src/video-generation-client.ts index 66ea4e6f..1fa7a22c 100644 --- a/packages/typescript/ai-client/src/video-generation-client.ts +++ b/packages/typescript/ai-client/src/video-generation-client.ts @@ -214,7 +214,7 @@ export class VideoGenerationClient { break } case 'RUN_ERROR': { - throw new Error(chunk.error.message) + throw new Error(chunk.error?.message ?? chunk.message) } } } diff --git a/packages/typescript/ai-client/tests/chat-client-abort.test.ts b/packages/typescript/ai-client/tests/chat-client-abort.test.ts index 2adffb1c..5853e75a 100644 --- a/packages/typescript/ai-client/tests/chat-client-abort.test.ts +++ b/packages/typescript/ai-client/tests/chat-client-abort.test.ts @@ -3,6 +3,10 @@ import { ChatClient } from '../src/chat-client' import type { ConnectionAdapter } from '../src/connection-adapters' import type { StreamChunk } from '@tanstack/ai' +/** Cast an event object to StreamChunk for type compatibility with EventType enum. */ +const asChunk = (chunk: Record) => + chunk as unknown as StreamChunk + describe('ChatClient - Abort Signal Handling', () => { let mockAdapter: ConnectionAdapter let receivedAbortSignal: AbortSignal | undefined @@ -16,29 +20,29 @@ describe('ChatClient - Abort Signal Handling', () => { receivedAbortSignal = abortSignal // Simulate streaming chunks (AG-UI format) - yield { + yield asChunk({ type: 'TEXT_MESSAGE_CONTENT', messageId: '1', model: 'test', timestamp: Date.now(), delta: 'Hello', content: 'Hello', - } - yield { + }) + yield asChunk({ type: 'TEXT_MESSAGE_CONTENT', messageId: '1', model: 'test', timestamp: Date.now(), delta: ' World', content: 'Hello World', - } - yield { + }) + yield asChunk({ type: 'RUN_FINISHED', runId: 'run-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', - } + }) }, } }) @@ -78,24 +82,24 @@ describe('ChatClient - Abort Signal Handling', () => { } try { - yield { + yield asChunk({ type: 'TEXT_MESSAGE_CONTENT', messageId: '1', model: 'test', timestamp: Date.now(), delta: 'Hello', content: 'Hello', - } + }) // Simulate long-running stream await new Promise((resolve) => setTimeout(resolve, 100)) - yield { + yield asChunk({ type: 'TEXT_MESSAGE_CONTENT', messageId: '1', model: 'test', timestamp: Date.now(), delta: ' World', content: 'Hello World', - } + }) } catch (err) { // Abort errors are expected if (err instanceof Error && err.name === 'AbortError') { @@ -133,28 +137,28 @@ describe('ChatClient - Abort Signal Handling', () => { const adapterWithPartial: ConnectionAdapter = { // eslint-disable-next-line @typescript-eslint/require-await async *connect(_messages, _data, abortSignal) { - yield { + yield asChunk({ type: 'TEXT_MESSAGE_CONTENT', messageId: '1', model: 'test', timestamp: Date.now(), delta: 'Hello', content: 'Hello', - } + }) yieldedChunks++ if (abortSignal?.aborted) { return } - yield { + yield asChunk({ type: 'TEXT_MESSAGE_CONTENT', messageId: '1', model: 'test', timestamp: Date.now(), delta: ' World', content: 'Hello World', - } + }) yieldedChunks++ }, } @@ -190,14 +194,14 @@ describe('ChatClient - Abort Signal Handling', () => { const adapterWithAbort: ConnectionAdapter = { // eslint-disable-next-line @typescript-eslint/require-await async *connect(_messages, _data, abortSignal) { - yield { + yield asChunk({ type: 'TEXT_MESSAGE_CONTENT', messageId: '1', model: 'test', timestamp: Date.now(), delta: 'Hello', content: 'Hello', - } + }) if (abortSignal?.aborted) { return @@ -230,14 +234,14 @@ describe('ChatClient - Abort Signal Handling', () => { it('should set isLoading to false after abort', async () => { const adapterWithAbort: ConnectionAdapter = { async *connect(_messages, _data, _abortSignal) { - yield { + yield asChunk({ type: 'TEXT_MESSAGE_CONTENT', messageId: '1', model: 'test', timestamp: Date.now(), delta: 'Hello', content: 'Hello', - } + }) await new Promise((resolve) => setTimeout(resolve, 50)) }, } @@ -272,13 +276,13 @@ describe('ChatClient - Abort Signal Handling', () => { if (abortSignal) { abortSignals.push(abortSignal) } - yield { + yield asChunk({ type: 'RUN_FINISHED', runId: 'run-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', - } + }) }, } diff --git a/packages/typescript/ai-client/tests/chat-client.test.ts b/packages/typescript/ai-client/tests/chat-client.test.ts index f4cbe68f..9e857c0b 100644 --- a/packages/typescript/ai-client/tests/chat-client.test.ts +++ b/packages/typescript/ai-client/tests/chat-client.test.ts @@ -12,6 +12,10 @@ import type { ConnectionAdapter } from '../src/connection-adapters' import type { StreamChunk } from '@tanstack/ai' import type { UIMessage } from '../src/types' +/** Cast an event object to StreamChunk for type compatibility with EventType enum. */ +const asChunk = (chunk: Record) => + chunk as unknown as StreamChunk + describe('ChatClient', () => { describe('constructor', () => { it('should create a client with default options', () => { @@ -152,7 +156,7 @@ describe('ChatClient', () => { model: 'test', timestamp: Date.now(), finishReason: 'stop', - }, + } as unknown as StreamChunk, ]) const client = new ChatClient({ connection: adapter }) @@ -258,7 +262,7 @@ describe('ChatClient', () => { timestamp: Date.now(), delta: 'H', content: 'H', - }, + } as unknown as StreamChunk, ]) const client = new ChatClient({ connection: adapter }) @@ -317,7 +321,7 @@ describe('ChatClient', () => { timestamp: Date.now(), delta: 'H', content: 'H', - }, + } as unknown as StreamChunk, ]) const client = new ChatClient({ connection: adapter }) @@ -351,7 +355,7 @@ describe('ChatClient', () => { runId: 'run-1', model: 'test', timestamp: Date.now(), - }, + } as unknown as StreamChunk, { type: 'TEXT_MESSAGE_CONTENT', messageId: 'msg-1', @@ -359,14 +363,14 @@ describe('ChatClient', () => { timestamp: Date.now(), delta: 'Hi', content: 'Hi', - }, + } as unknown as StreamChunk, { type: 'RUN_FINISHED', runId: 'run-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', - }, + } as unknown as StreamChunk, ] const adapter = createSubscribeAdapter(chunks) const generatingChanges: Array = [] @@ -390,14 +394,14 @@ describe('ChatClient', () => { runId: 'run-1', model: 'test', timestamp: Date.now(), - }, + } as unknown as StreamChunk, { type: 'RUN_ERROR', runId: 'run-1', model: 'test', timestamp: Date.now(), error: { message: 'something went wrong' }, - }, + } as unknown as StreamChunk, ] const adapter = createSubscribeAdapter(chunks) const generatingChanges: Array = [] @@ -421,7 +425,7 @@ describe('ChatClient', () => { runId: 'run-1', model: 'test', timestamp: Date.now(), - }, + } as unknown as StreamChunk, { type: 'TEXT_MESSAGE_CONTENT', messageId: 'msg-1', @@ -429,14 +433,14 @@ describe('ChatClient', () => { timestamp: Date.now(), delta: 'Hi', content: 'Hi', - }, + } as unknown as StreamChunk, { type: 'RUN_FINISHED', runId: 'run-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', - }, + } as unknown as StreamChunk, ] const adapter = createSubscribeAdapter(chunks) const client = new ChatClient({ connection: adapter }) @@ -457,12 +461,12 @@ describe('ChatClient', () => { while (!signal?.aborted) { if (!yieldedStart) { yieldedStart = true - yield { + yield asChunk({ type: 'RUN_STARTED' as const, runId: 'run-1', model: 'test', timestamp: Date.now(), - } + }) } await new Promise((resolve) => { const onAbort = () => resolve() @@ -500,12 +504,12 @@ describe('ChatClient', () => { while (!signal?.aborted) { if (!yieldedStart) { yieldedStart = true - yield { + yield asChunk({ type: 'RUN_STARTED' as const, runId: 'run-1', model: 'test', timestamp: Date.now(), - } + }) } await new Promise((resolve) => { const onAbort = () => resolve() @@ -543,13 +547,13 @@ describe('ChatClient', () => { runId: 'run-1', model: 'test', timestamp: Date.now(), - }, + } as unknown as StreamChunk, { type: 'RUN_STARTED', runId: 'run-1', model: 'test', timestamp: Date.now(), - }, + } as unknown as StreamChunk, { type: 'TEXT_MESSAGE_CONTENT', messageId: 'msg-1', @@ -557,21 +561,21 @@ describe('ChatClient', () => { timestamp: Date.now(), delta: 'Hi', content: 'Hi', - }, + } as unknown as StreamChunk, { type: 'RUN_FINISHED', runId: 'run-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', - }, + } as unknown as StreamChunk, { type: 'RUN_FINISHED', runId: 'run-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', - }, + } as unknown as StreamChunk, ] const adapter = createSubscribeAdapter(chunks) const generatingChanges: Array = [] @@ -594,7 +598,7 @@ describe('ChatClient', () => { runId: 'run-1', model: 'test', timestamp: Date.now(), - }, + } as unknown as StreamChunk, { type: 'TEXT_MESSAGE_CONTENT', messageId: 'msg-1', @@ -602,14 +606,14 @@ describe('ChatClient', () => { timestamp: Date.now(), delta: 'A', content: 'A', - }, + } as unknown as StreamChunk, { type: 'RUN_FINISHED', runId: 'run-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', - }, + } as unknown as StreamChunk, ] const adapter = createSubscribeAdapter(chunks) const generatingChanges: Array = [] @@ -668,13 +672,13 @@ describe('ChatClient', () => { runId: 'run-1', model: 'test', timestamp: Date.now(), - }, + } as unknown as StreamChunk, { type: 'RUN_STARTED', runId: 'run-2', model: 'test', timestamp: Date.now(), - }, + } as unknown as StreamChunk, ) wake.fn?.() await new Promise((resolve) => setTimeout(resolve, 20)) @@ -688,7 +692,7 @@ describe('ChatClient', () => { model: 'test', timestamp: Date.now(), finishReason: 'stop', - }) + } as unknown as StreamChunk) wake.fn?.() await new Promise((resolve) => setTimeout(resolve, 20)) @@ -701,7 +705,7 @@ describe('ChatClient', () => { model: 'test', timestamp: Date.now(), finishReason: 'stop', - }) + } as unknown as StreamChunk) wake.fn?.() await new Promise((resolve) => setTimeout(resolve, 20)) @@ -753,13 +757,13 @@ describe('ChatClient', () => { runId: 'run-1', model: 'test', timestamp: Date.now(), - }, + } as unknown as StreamChunk, { type: 'RUN_STARTED', runId: 'run-2', model: 'test', timestamp: Date.now(), - }, + } as unknown as StreamChunk, ) wake.fn?.() await new Promise((resolve) => setTimeout(resolve, 20)) @@ -772,7 +776,7 @@ describe('ChatClient', () => { model: 'test', timestamp: Date.now(), error: { message: 'session crashed' }, - }) + } as unknown as StreamChunk) wake.fn?.() await new Promise((resolve) => setTimeout(resolve, 20)) @@ -788,12 +792,12 @@ describe('ChatClient', () => { subscribe: async function* (_signal?: AbortSignal) { if (!yieldedStart) { yieldedStart = true - yield { + yield asChunk({ type: 'RUN_STARTED' as const, runId: 'run-1', model: 'test', timestamp: Date.now(), - } + }) await new Promise((resolve) => setTimeout(resolve, 10)) } throw new Error('subscription failed') @@ -1284,7 +1288,7 @@ describe('ChatClient', () => { timestamp: Date.now(), delta: 'H', content: 'H', - }, + } as unknown as StreamChunk, ], chunkDelay: 50, }) @@ -1926,21 +1930,21 @@ describe('ChatClient', () => { model: 'test', timestamp: Date.now(), index: 0, - }, + } as unknown as StreamChunk, { type: 'TOOL_CALL_ARGS', toolCallId: 'tc-2', model: 'test', timestamp: Date.now(), delta: '{}', - }, + } as unknown as StreamChunk, { type: 'TOOL_CALL_END', toolCallId: 'tc-2', toolName: 'dangerous_tool_2', model: 'test', timestamp: Date.now(), - }, + } as unknown as StreamChunk, { type: 'CUSTOM', model: 'test', @@ -1952,7 +1956,7 @@ describe('ChatClient', () => { input: {}, approval: { id: 'approval-2', needsApproval: true }, }, - }, + } as unknown as StreamChunk, ] for (const chunk of preChunks) yield chunk @@ -1961,13 +1965,13 @@ describe('ChatClient', () => { resolveStreamPause = resolve }) - yield { + yield asChunk({ type: 'RUN_FINISHED' as const, runId: 'run-2', model: 'test', timestamp: Date.now(), finishReason: 'tool_calls' as const, - } + }) } else if (streamCount === 3) { // Third stream (after second approval): final text response const chunks = createTextChunks('All done!') @@ -2067,7 +2071,7 @@ describe('ChatClient', () => { runId: 'run-a', model: 'test', timestamp: Date.now(), - }, + } as unknown as StreamChunk, { type: 'TEXT_MESSAGE_START', messageId: 'msg-a', @@ -2093,7 +2097,7 @@ describe('ChatClient', () => { runId: 'run-b', model: 'test', timestamp: Date.now(), - }, + } as unknown as StreamChunk, { type: 'TEXT_MESSAGE_START', messageId: 'msg-b', @@ -2119,7 +2123,7 @@ describe('ChatClient', () => { model: 'test', timestamp: Date.now(), finishReason: 'stop', - }) + } as unknown as StreamChunk) wake.fn?.() await new Promise((resolve) => setTimeout(resolve, 20)) @@ -2158,7 +2162,7 @@ describe('ChatClient', () => { model: 'test', timestamp: Date.now(), finishReason: 'stop', - }) + } as unknown as StreamChunk) wake.fn?.() await new Promise((resolve) => setTimeout(resolve, 20)) @@ -2222,7 +2226,7 @@ describe('ChatClient', () => { runId: 'run-1', model: 'test', timestamp: Date.now(), - }, + } as unknown as StreamChunk, { type: 'TEXT_MESSAGE_CONTENT', messageId: 'asst-1', @@ -2236,7 +2240,7 @@ describe('ChatClient', () => { model: 'test', timestamp: Date.now(), finishReason: 'stop', - }, + } as unknown as StreamChunk, ) wake.fn?.() await new Promise((resolve) => setTimeout(resolve, 20)) diff --git a/packages/typescript/ai-client/tests/connection-adapters.test.ts b/packages/typescript/ai-client/tests/connection-adapters.test.ts index a5addde2..0919796a 100644 --- a/packages/typescript/ai-client/tests/connection-adapters.test.ts +++ b/packages/typescript/ai-client/tests/connection-adapters.test.ts @@ -8,6 +8,10 @@ import { } from '../src/connection-adapters' import type { StreamChunk } from '@tanstack/ai' +/** Cast an event object to StreamChunk for type compatibility with EventType enum. */ +const asChunk = (chunk: Record) => + chunk as unknown as StreamChunk + describe('connection-adapters', () => { let originalFetch: typeof fetch let fetchMock: ReturnType @@ -778,14 +782,14 @@ describe('connection-adapters', () => { describe('stream', () => { it('should delegate to stream factory', async () => { const streamFactory = vi.fn().mockImplementation(function* () { - yield { + yield asChunk({ type: 'TEXT_MESSAGE_CONTENT', messageId: 'msg-1', model: 'test', timestamp: Date.now(), delta: 'Hello', content: 'Hello', - } + }) }) const adapter = stream(streamFactory) @@ -803,13 +807,13 @@ describe('connection-adapters', () => { it('should pass data to stream factory', async () => { const streamFactory = vi.fn().mockImplementation(function* () { - yield { + yield asChunk({ type: 'RUN_FINISHED', runId: 'run-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', - } + }) }) const adapter = stream(streamFactory) @@ -863,14 +867,14 @@ describe('connection-adapters', () => { it('should synthesize RUN_FINISHED when wrapped connect stream has no terminal event', async () => { const base = stream(async function* () { - yield { + yield asChunk({ type: 'TEXT_MESSAGE_CONTENT', messageId: 'msg-1', model: 'test', timestamp: Date.now(), delta: 'Hi', content: 'Hi', - } + }) }) const adapter = normalizeConnectionAdapter(base) @@ -922,13 +926,13 @@ describe('connection-adapters', () => { it('should not synthesize duplicate RUN_ERROR when stream already emitted one before throwing', async () => { const base = stream(async function* () { - yield { + yield asChunk({ type: 'RUN_ERROR', timestamp: Date.now(), error: { message: 'already failed', }, - } + }) throw new Error('connect exploded') }) @@ -953,7 +957,7 @@ describe('connection-adapters', () => { expect(received).toHaveLength(1) expect(received[0]?.type).toBe('RUN_ERROR') if (received[0]?.type === 'RUN_ERROR') { - expect(received[0].error.message).toBe('already failed') + expect(received[0].error?.message).toBe('already failed') } }) }) @@ -961,14 +965,14 @@ describe('connection-adapters', () => { describe('rpcStream', () => { it('should delegate to RPC call', async () => { const rpcCall = vi.fn().mockImplementation(function* () { - yield { + yield asChunk({ type: 'TEXT_MESSAGE_CONTENT', messageId: 'msg-1', model: 'test', timestamp: Date.now(), delta: 'Hello', content: 'Hello', - } + }) }) const adapter = rpcStream(rpcCall) @@ -990,13 +994,13 @@ describe('connection-adapters', () => { it('should pass messages and data to RPC call', async () => { const rpcCall = vi.fn().mockImplementation(function* () { - yield { + yield asChunk({ type: 'RUN_FINISHED', runId: 'run-1', model: 'test', timestamp: Date.now(), finishReason: 'stop', - } + }) }) const adapter = rpcStream(rpcCall) diff --git a/packages/typescript/ai-client/tests/generation-client.test.ts b/packages/typescript/ai-client/tests/generation-client.test.ts index ec70ae74..e17a09f2 100644 --- a/packages/typescript/ai-client/tests/generation-client.test.ts +++ b/packages/typescript/ai-client/tests/generation-client.test.ts @@ -3,6 +3,10 @@ import { GenerationClient } from '../src/generation-client' import type { StreamChunk } from '@tanstack/ai' import type { ConnectConnectionAdapter } from '../src/connection-adapters' +/** Cast an event object to StreamChunk for type compatibility with EventType enum. */ +const asChunk = (chunk: Record) => + chunk as unknown as StreamChunk + // Helper to create a mock connect-based adapter from StreamChunks function createMockConnection( chunks: Array, @@ -129,19 +133,19 @@ describe('GenerationClient', () => { const onResult = vi.fn() const connection = createMockConnection([ - { type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }, - { + asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), + asChunk({ type: 'CUSTOM', name: 'generation:result', value: mockResult, timestamp: Date.now(), - }, - { + }), + asChunk({ type: 'RUN_FINISHED', runId: 'run-1', finishReason: 'stop', timestamp: Date.now(), - }, + }), ]) const client = new GenerationClient({ @@ -160,13 +164,13 @@ describe('GenerationClient', () => { const onError = vi.fn() const connection = createMockConnection([ - { type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }, - { + asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), + asChunk({ type: 'RUN_ERROR', runId: 'run-1', error: { message: 'Generation failed' }, timestamp: Date.now(), - }, + }), ]) const client = new GenerationClient({ @@ -185,25 +189,25 @@ describe('GenerationClient', () => { const onProgress = vi.fn() const connection = createMockConnection([ - { type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }, - { + asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), + asChunk({ type: 'CUSTOM', name: 'generation:progress', value: { progress: 50, message: 'Halfway' }, timestamp: Date.now(), - }, - { + }), + asChunk({ type: 'CUSTOM', name: 'generation:result', value: { id: '1' }, timestamp: Date.now(), - }, - { + }), + asChunk({ type: 'RUN_FINISHED', runId: 'run-1', finishReason: 'stop', timestamp: Date.now(), - }, + }), ]) const client = new GenerationClient({ @@ -220,19 +224,23 @@ describe('GenerationClient', () => { const onChunk = vi.fn() const chunks: Array = [ - { type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }, + { + type: 'RUN_STARTED', + runId: 'run-1', + timestamp: Date.now(), + } as unknown as StreamChunk, { type: 'CUSTOM', name: 'generation:result', value: { id: '1' }, timestamp: Date.now(), - }, + } as unknown as StreamChunk, { type: 'RUN_FINISHED', runId: 'run-1', finishReason: 'stop', timestamp: Date.now(), - }, + } as unknown as StreamChunk, ] const connection = createMockConnection(chunks) @@ -249,18 +257,18 @@ describe('GenerationClient', () => { it('should pass body and input as data to connection', async () => { const connectSpy = vi.fn(async function* () { - yield { + yield asChunk({ type: 'CUSTOM' as const, name: 'generation:result', value: { id: '1' }, timestamp: Date.now(), - } - yield { + }) + yield asChunk({ type: 'RUN_FINISHED' as const, runId: 'run-1', finishReason: 'stop' as const, timestamp: Date.now(), - } + }) }) const connection: ConnectConnectionAdapter = { @@ -326,12 +334,12 @@ describe('GenerationClient', () => { describe('updateOptions()', () => { it('should update body without recreating client', async () => { const connectSpy = vi.fn(async function* () { - yield { + yield asChunk({ type: 'RUN_FINISHED' as const, runId: 'run-1', finishReason: 'stop' as const, timestamp: Date.now(), - } + }) }) const connection: ConnectConnectionAdapter = { connect: connectSpy } @@ -358,23 +366,23 @@ describe('GenerationClient', () => { const connection: ConnectConnectionAdapter = { async *connect(_msgs, _data, signal) { - yield { + yield asChunk({ type: 'RUN_STARTED' as const, runId: 'run-1', timestamp: Date.now(), - } + }) // Wait until abort is triggered await new Promise((resolve) => { signal?.addEventListener('abort', () => resolve()) }) // Adapter honors abort signal and stops yielding if (signal?.aborted) return - yield { + yield asChunk({ type: 'CUSTOM' as const, name: 'generation:result', value: { id: '1' }, timestamp: Date.now(), - } + }) }, } @@ -456,13 +464,13 @@ describe('GenerationClient', () => { const onResult = vi.fn() const connection = createMockConnection([ - { type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }, - { + asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), + asChunk({ type: 'RUN_FINISHED', runId: 'run-1', finishReason: 'stop', timestamp: Date.now(), - }, + }), ]) const client = new GenerationClient({ @@ -481,19 +489,19 @@ describe('GenerationClient', () => { const onChunk = vi.fn() const connection = createMockConnection([ - { type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }, - { + asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), + asChunk({ type: 'CUSTOM', name: 'unknown:event', value: { foo: 'bar' }, timestamp: Date.now(), - }, - { + }), + asChunk({ type: 'RUN_FINISHED', runId: 'run-1', finishReason: 'stop', timestamp: Date.now(), - }, + }), ]) const client = new GenerationClient({ @@ -586,19 +594,19 @@ describe('GenerationClient', () => { it('should transform result from stream CUSTOM event', async () => { const connection = createMockConnection([ - { type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }, - { + asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), + asChunk({ type: 'CUSTOM', name: 'generation:result', value: { id: '1', images: [] }, timestamp: Date.now(), - }, - { + }), + asChunk({ type: 'RUN_FINISHED', runId: 'run-1', finishReason: 'stop', timestamp: Date.now(), - }, + }), ]) const client = new GenerationClient< diff --git a/packages/typescript/ai-client/tests/video-generation-client.test.ts b/packages/typescript/ai-client/tests/video-generation-client.test.ts index 6f4397cd..7118dbf1 100644 --- a/packages/typescript/ai-client/tests/video-generation-client.test.ts +++ b/packages/typescript/ai-client/tests/video-generation-client.test.ts @@ -3,6 +3,10 @@ import { VideoGenerationClient } from '../src/video-generation-client' import type { StreamChunk } from '@tanstack/ai' import type { ConnectConnectionAdapter } from '../src/connection-adapters' +/** Cast an event object to StreamChunk for type compatibility with EventType enum. */ +const asChunk = (chunk: Record) => + chunk as unknown as StreamChunk + // Helper to create a mock connect-based adapter from StreamChunks function createMockConnection( chunks: Array, @@ -141,14 +145,14 @@ describe('VideoGenerationClient', () => { const onStatusUpdate = vi.fn() const connection = createMockConnection([ - { type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }, - { + asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), + asChunk({ type: 'CUSTOM', name: 'video:job:created', value: { jobId: 'job-123' }, timestamp: Date.now(), - }, - { + }), + asChunk({ type: 'CUSTOM', name: 'video:status', value: { @@ -157,8 +161,8 @@ describe('VideoGenerationClient', () => { progress: 50, }, timestamp: Date.now(), - }, - { + }), + asChunk({ type: 'CUSTOM', name: 'video:status', value: { @@ -167,8 +171,8 @@ describe('VideoGenerationClient', () => { progress: 100, }, timestamp: Date.now(), - }, - { + }), + asChunk({ type: 'CUSTOM', name: 'generation:result', value: { @@ -177,13 +181,13 @@ describe('VideoGenerationClient', () => { url: 'https://example.com/video.mp4', }, timestamp: Date.now(), - }, - { + }), + asChunk({ type: 'RUN_FINISHED', runId: 'run-1', finishReason: 'stop', timestamp: Date.now(), - }, + }), ]) const client = new VideoGenerationClient({ @@ -211,8 +215,8 @@ describe('VideoGenerationClient', () => { const onVideoStatusChange = vi.fn() const connection = createMockConnection([ - { type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }, - { + asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), + asChunk({ type: 'CUSTOM', name: 'video:status', value: { @@ -221,8 +225,8 @@ describe('VideoGenerationClient', () => { progress: 25, }, timestamp: Date.now(), - }, - { + }), + asChunk({ type: 'CUSTOM', name: 'generation:result', value: { @@ -231,13 +235,13 @@ describe('VideoGenerationClient', () => { url: 'https://example.com/video.mp4', }, timestamp: Date.now(), - }, - { + }), + asChunk({ type: 'RUN_FINISHED', runId: 'run-1', finishReason: 'stop', timestamp: Date.now(), - }, + }), ]) const client = new VideoGenerationClient({ @@ -264,13 +268,13 @@ describe('VideoGenerationClient', () => { const onError = vi.fn() const connection = createMockConnection([ - { type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }, - { + asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), + asChunk({ type: 'RUN_ERROR', runId: 'run-1', error: { message: 'Video generation failed' }, timestamp: Date.now(), - }, + }), ]) const client = new VideoGenerationClient({ @@ -289,8 +293,8 @@ describe('VideoGenerationClient', () => { const onProgress = vi.fn() const connection = createMockConnection([ - { type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }, - { + asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), + asChunk({ type: 'CUSTOM', name: 'video:status', value: { @@ -299,13 +303,13 @@ describe('VideoGenerationClient', () => { progress: 50, }, timestamp: Date.now(), - }, - { + }), + asChunk({ type: 'RUN_FINISHED', runId: 'run-1', finishReason: 'stop', timestamp: Date.now(), - }, + }), ]) const client = new VideoGenerationClient({ @@ -322,19 +326,19 @@ describe('VideoGenerationClient', () => { const onProgress = vi.fn() const connection = createMockConnection([ - { type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }, - { + asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), + asChunk({ type: 'CUSTOM', name: 'generation:progress', value: { progress: 75, message: 'Almost done' }, timestamp: Date.now(), - }, - { + }), + asChunk({ type: 'RUN_FINISHED', runId: 'run-1', finishReason: 'stop', timestamp: Date.now(), - }, + }), ]) const client = new VideoGenerationClient({ @@ -351,8 +355,8 @@ describe('VideoGenerationClient', () => { const onChunk = vi.fn() const connection = createMockConnection([ - { type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }, - { + asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), + asChunk({ type: 'CUSTOM', name: 'generation:result', value: { @@ -361,13 +365,13 @@ describe('VideoGenerationClient', () => { url: 'https://example.com/video.mp4', }, timestamp: Date.now(), - }, - { + }), + asChunk({ type: 'RUN_FINISHED', runId: 'run-1', finishReason: 'stop', timestamp: Date.now(), - }, + }), ]) const client = new VideoGenerationClient({ @@ -382,12 +386,12 @@ describe('VideoGenerationClient', () => { it('should pass body and input as data to connection', async () => { const connectSpy = vi.fn(async function* () { - yield { + yield asChunk({ type: 'RUN_FINISHED' as const, runId: 'run-1', finishReason: 'stop' as const, timestamp: Date.now(), - } + }) }) const connection: ConnectConnectionAdapter = { connect: connectSpy } @@ -441,14 +445,14 @@ describe('VideoGenerationClient', () => { const onVideoStatusChange = vi.fn() const connection = createMockConnection([ - { type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }, - { + asChunk({ type: 'RUN_STARTED', runId: 'run-1', timestamp: Date.now() }), + asChunk({ type: 'CUSTOM', name: 'video:job:created', value: { jobId: 'job-123' }, timestamp: Date.now(), - }, - { + }), + asChunk({ type: 'CUSTOM', name: 'video:status', value: { @@ -457,8 +461,8 @@ describe('VideoGenerationClient', () => { progress: 50, }, timestamp: Date.now(), - }, - { + }), + asChunk({ type: 'CUSTOM', name: 'generation:result', value: { @@ -467,13 +471,13 @@ describe('VideoGenerationClient', () => { url: 'https://example.com/video.mp4', }, timestamp: Date.now(), - }, - { + }), + asChunk({ type: 'RUN_FINISHED', runId: 'run-1', finishReason: 'stop', timestamp: Date.now(), - }, + }), ]) const client = new VideoGenerationClient({ @@ -499,12 +503,12 @@ describe('VideoGenerationClient', () => { describe('updateOptions()', () => { it('should update body without recreating client', async () => { const connectSpy = vi.fn(async function* () { - yield { + yield asChunk({ type: 'RUN_FINISHED' as const, runId: 'run-1', finishReason: 'stop' as const, timestamp: Date.now(), - } + }) }) const connection: ConnectConnectionAdapter = { connect: connectSpy } @@ -532,24 +536,24 @@ describe('VideoGenerationClient', () => { const connection: ConnectConnectionAdapter = { async *connect(_msgs, _data, signal) { - yield { + yield asChunk({ type: 'RUN_STARTED' as const, runId: 'run-1', timestamp: Date.now(), - } - yield { + }) + yield asChunk({ type: 'CUSTOM' as const, name: 'video:job:created', value: { jobId: 'job-123' }, timestamp: Date.now(), - } + }) // Wait until abort is triggered await new Promise((resolve) => { signal?.addEventListener('abort', () => resolve()) }) // Adapter honors abort signal and stops yielding if (signal?.aborted) return - yield { + yield asChunk({ type: 'CUSTOM' as const, name: 'generation:result', value: { @@ -558,7 +562,7 @@ describe('VideoGenerationClient', () => { url: 'https://example.com/video.mp4', }, timestamp: Date.now(), - } + }) }, } diff --git a/packages/typescript/ai-event-client/src/devtools-middleware.ts b/packages/typescript/ai-event-client/src/devtools-middleware.ts index c034c551..408968ff 100644 --- a/packages/typescript/ai-event-client/src/devtools-middleware.ts +++ b/packages/typescript/ai-event-client/src/devtools-middleware.ts @@ -254,7 +254,7 @@ export function devtoolsMiddleware(): DevtoolsChatMiddleware { } case 'TOOL_CALL_START': { const toolIndex = chunk.index ?? 0 - const toolName = chunk.toolCallName ?? (chunk as any).toolName + const toolName = chunk.toolCallName activeToolCalls.set(chunk.toolCallId, { toolName, index: toolIndex, diff --git a/packages/typescript/ai-gemini/src/adapters/summarize.ts b/packages/typescript/ai-gemini/src/adapters/summarize.ts index 858c8b78..21d45996 100644 --- a/packages/typescript/ai-gemini/src/adapters/summarize.ts +++ b/packages/typescript/ai-gemini/src/adapters/summarize.ts @@ -13,6 +13,10 @@ import type { SummarizationResult, } from '@tanstack/ai' +/** Cast an event object to StreamChunk. */ +const asChunk = (chunk: Record) => + chunk as unknown as StreamChunk + /** * Configuration for Gemini summarize adapter */ @@ -161,14 +165,14 @@ export class GeminiSummarizeAdapter< for (const part of chunk.candidates[0].content.parts) { if (part.text) { accumulatedContent += part.text - yield { + yield asChunk({ type: 'TEXT_MESSAGE_CONTENT', messageId: id, model, timestamp: Date.now(), delta: part.text, content: accumulatedContent, - } + }) } } } @@ -180,7 +184,7 @@ export class GeminiSummarizeAdapter< finishReason === FinishReason.MAX_TOKENS || finishReason === FinishReason.SAFETY ) { - yield { + yield asChunk({ type: 'RUN_FINISHED', runId: id, model, @@ -196,7 +200,7 @@ export class GeminiSummarizeAdapter< completionTokens: outputTokens, totalTokens: inputTokens + outputTokens, }, - } + }) } } } diff --git a/packages/typescript/ai-gemini/src/adapters/text.ts b/packages/typescript/ai-gemini/src/adapters/text.ts index e4c7d9e2..28f510b5 100644 --- a/packages/typescript/ai-gemini/src/adapters/text.ts +++ b/packages/typescript/ai-gemini/src/adapters/text.ts @@ -34,6 +34,11 @@ import type { ExternalTextProviderOptions } from '../text/text-provider-options' import type { GeminiMessageMetadataByModality } from '../message-types' import type { GeminiClientConfig } from '../utils' +/** Cast an event object to StreamChunk. Adapters construct events with string + * literal types which are structurally compatible with the EventType enum. */ +const asChunk = (chunk: Record) => + chunk as unknown as StreamChunk + /** * Configuration for Gemini text adapter */ @@ -109,7 +114,7 @@ export class GeminiTextAdapter< yield* this.processStreamChunks(result, options) } catch (error) { const timestamp = Date.now() - yield { + yield asChunk({ type: 'RUN_ERROR', model: options.model, timestamp, @@ -123,7 +128,7 @@ export class GeminiTextAdapter< ? error.message : 'An unknown error occurred during the chat stream.', }, - } + }) } } @@ -228,13 +233,13 @@ export class GeminiTextAdapter< // Emit RUN_STARTED on first chunk if (!hasEmittedRunStarted) { hasEmittedRunStarted = true - yield { + yield asChunk({ type: 'RUN_STARTED', runId, threadId, model, timestamp, - } + }) } if (chunk.candidates?.[0]?.content?.parts) { @@ -250,44 +255,44 @@ export class GeminiTextAdapter< reasoningMessageId = generateId(this.name) // Spec REASONING events - yield { + yield asChunk({ type: 'REASONING_START', messageId: reasoningMessageId, model, timestamp, - } - yield { + }) + yield asChunk({ type: 'REASONING_MESSAGE_START', messageId: reasoningMessageId, role: 'reasoning' as const, model, timestamp, - } + }) // Legacy STEP events (kept during transition) - yield { + yield asChunk({ type: 'STEP_STARTED', stepName: stepId, stepId, model, timestamp, stepType: 'thinking', - } + }) } accumulatedThinking += part.text // Spec REASONING content event - yield { + yield asChunk({ type: 'REASONING_MESSAGE_CONTENT', messageId: reasoningMessageId!, delta: part.text, model, timestamp, - } + }) // Legacy STEP event - yield { + yield asChunk({ type: 'STEP_FINISHED', stepName: stepId || generateId(this.name), stepId: stepId || generateId(this.name), @@ -295,47 +300,47 @@ export class GeminiTextAdapter< timestamp, delta: part.text, content: accumulatedThinking, - } + }) } else if (part.text.trim()) { // Close reasoning before text starts if (reasoningMessageId && !hasClosedReasoning) { hasClosedReasoning = true - yield { + yield asChunk({ type: 'REASONING_MESSAGE_END', messageId: reasoningMessageId, model, timestamp, - } - yield { + }) + yield asChunk({ type: 'REASONING_END', messageId: reasoningMessageId, model, timestamp, - } + }) } // Skip whitespace-only text parts (e.g. "\n" during auto-continuation) // Emit TEXT_MESSAGE_START on first text content if (!hasEmittedTextMessageStart) { hasEmittedTextMessageStart = true - yield { + yield asChunk({ type: 'TEXT_MESSAGE_START', messageId, model, timestamp, role: 'assistant', - } + }) } accumulatedContent += part.text - yield { + yield asChunk({ type: 'TEXT_MESSAGE_CONTENT', messageId, model, timestamp, delta: part.text, content: accumulatedContent, - } + }) } } @@ -380,7 +385,7 @@ export class GeminiTextAdapter< // Emit TOOL_CALL_START if not already started if (!toolCallData.started) { toolCallData.started = true - yield { + yield asChunk({ type: 'TOOL_CALL_START', toolCallId, toolCallName: toolCallData.name, @@ -393,18 +398,18 @@ export class GeminiTextAdapter< thoughtSignature: toolCallData.thoughtSignature, }, }), - } + }) } // Emit TOOL_CALL_ARGS - yield { + yield asChunk({ type: 'TOOL_CALL_ARGS', toolCallId, model, timestamp, delta: toolCallData.args, args: toolCallData.args, - } + }) } } } else if (chunk.data && chunk.data.trim()) { @@ -412,24 +417,24 @@ export class GeminiTextAdapter< // Emit TEXT_MESSAGE_START on first text content if (!hasEmittedTextMessageStart) { hasEmittedTextMessageStart = true - yield { + yield asChunk({ type: 'TEXT_MESSAGE_START', messageId, model, timestamp, role: 'assistant', - } + }) } accumulatedContent += chunk.data - yield { + yield asChunk({ type: 'TEXT_MESSAGE_CONTENT', messageId, model, timestamp, delta: chunk.data, content: accumulatedContent, - } + }) } if (chunk.candidates?.[0]?.finishReason) { @@ -458,7 +463,7 @@ export class GeminiTextAdapter< }) // Emit TOOL_CALL_START - yield { + yield asChunk({ type: 'TOOL_CALL_START', toolCallId, toolCallName: functionCall.name || '', @@ -466,7 +471,7 @@ export class GeminiTextAdapter< model, timestamp, index: nextToolIndex - 1, - } + }) // Emit TOOL_CALL_END with parsed input let parsedInput: unknown = {} @@ -479,7 +484,7 @@ export class GeminiTextAdapter< parsedInput = {} } - yield { + yield asChunk({ type: 'TOOL_CALL_END', toolCallId, toolCallName: functionCall.name || '', @@ -487,7 +492,7 @@ export class GeminiTextAdapter< model, timestamp, input: parsedInput, - } + }) } } } @@ -502,7 +507,7 @@ export class GeminiTextAdapter< parsedInput = {} } - yield { + yield asChunk({ type: 'TOOL_CALL_END', toolCallId, toolCallName: toolCallData.name, @@ -510,7 +515,7 @@ export class GeminiTextAdapter< model, timestamp, input: parsedInput, - } + }) } // Reset so a new TEXT_MESSAGE_START is emitted if text follows tool calls @@ -519,7 +524,7 @@ export class GeminiTextAdapter< } if (finishReason === FinishReason.MAX_TOKENS) { - yield { + yield asChunk({ type: 'RUN_ERROR', runId, model, @@ -532,37 +537,37 @@ export class GeminiTextAdapter< 'The response was cut off because the maximum token limit was reached.', code: 'max_tokens', }, - } + }) } // Close reasoning events if still open if (reasoningMessageId && !hasClosedReasoning) { hasClosedReasoning = true - yield { + yield asChunk({ type: 'REASONING_MESSAGE_END', messageId: reasoningMessageId, model, timestamp, - } - yield { + }) + yield asChunk({ type: 'REASONING_END', messageId: reasoningMessageId, model, timestamp, - } + }) } // Emit TEXT_MESSAGE_END if we had text content if (hasEmittedTextMessageStart) { - yield { + yield asChunk({ type: 'TEXT_MESSAGE_END', messageId, model, timestamp, - } + }) } - yield { + yield asChunk({ type: 'RUN_FINISHED', runId, threadId, @@ -576,7 +581,7 @@ export class GeminiTextAdapter< totalTokens: chunk.usageMetadata.totalTokenCount ?? 0, } : undefined, - } + }) } } } diff --git a/packages/typescript/ai-grok/src/adapters/text.ts b/packages/typescript/ai-grok/src/adapters/text.ts index 000f50af..fc0f3a8f 100644 --- a/packages/typescript/ai-grok/src/adapters/text.ts +++ b/packages/typescript/ai-grok/src/adapters/text.ts @@ -31,6 +31,11 @@ import type { } from '../message-types' import type { GrokClientConfig } from '../utils' +/** Cast an event object to StreamChunk. Adapters construct events with string + * literal types which are structurally compatible with the EventType enum. */ +const asChunk = (chunk: Record) => + chunk as unknown as StreamChunk + /** * Configuration for Grok text adapter */ @@ -93,17 +98,17 @@ export class GrokTextAdapter< // Emit RUN_STARTED if not yet emitted if (!aguiState.hasEmittedRunStarted) { aguiState.hasEmittedRunStarted = true - yield { + yield asChunk({ type: 'RUN_STARTED', runId: aguiState.runId, threadId: aguiState.threadId, model: options.model, timestamp, - } + }) } // Emit AG-UI RUN_ERROR - yield { + yield asChunk({ type: 'RUN_ERROR', runId: aguiState.runId, model: options.model, @@ -114,7 +119,7 @@ export class GrokTextAdapter< message: err.message || 'Unknown error', code: err.code, }, - } + }) console.error('>>> chatStream: Fatal error during response creation <<<') console.error('>>> Error message:', err.message) @@ -225,13 +230,13 @@ export class GrokTextAdapter< // Emit RUN_STARTED on first chunk if (!aguiState.hasEmittedRunStarted) { aguiState.hasEmittedRunStarted = true - yield { + yield asChunk({ type: 'RUN_STARTED', runId: aguiState.runId, threadId: aguiState.threadId, model: chunk.model || options.model, timestamp, - } + }) } const delta = choice.delta @@ -243,26 +248,26 @@ export class GrokTextAdapter< // Emit TEXT_MESSAGE_START on first text content if (!hasEmittedTextMessageStart) { hasEmittedTextMessageStart = true - yield { + yield asChunk({ type: 'TEXT_MESSAGE_START', messageId: aguiState.messageId, model: chunk.model || options.model, timestamp, role: 'assistant', - } + }) } accumulatedContent += deltaContent // Emit AG-UI TEXT_MESSAGE_CONTENT - yield { + yield asChunk({ type: 'TEXT_MESSAGE_CONTENT', messageId: aguiState.messageId, model: chunk.model || options.model, timestamp, delta: deltaContent, content: accumulatedContent, - } + }) } // Handle tool calls - they come in as deltas @@ -296,7 +301,7 @@ export class GrokTextAdapter< // Emit TOOL_CALL_START when we have id and name if (toolCall.id && toolCall.name && !toolCall.started) { toolCall.started = true - yield { + yield asChunk({ type: 'TOOL_CALL_START', toolCallId: toolCall.id, toolCallName: toolCall.name, @@ -304,18 +309,18 @@ export class GrokTextAdapter< model: chunk.model || options.model, timestamp, index, - } + }) } // Emit TOOL_CALL_ARGS for argument deltas if (toolCallDelta.function?.arguments && toolCall.started) { - yield { + yield asChunk({ type: 'TOOL_CALL_ARGS', toolCallId: toolCall.id, model: chunk.model || options.model, timestamp, delta: toolCallDelta.function.arguments, - } + }) } } } @@ -339,7 +344,7 @@ export class GrokTextAdapter< } // Emit AG-UI TOOL_CALL_END - yield { + yield asChunk({ type: 'TOOL_CALL_END', toolCallId: toolCall.id, toolCallName: toolCall.name, @@ -347,7 +352,7 @@ export class GrokTextAdapter< model: chunk.model || options.model, timestamp, input: parsedInput, - } + }) } } @@ -359,16 +364,16 @@ export class GrokTextAdapter< // Emit TEXT_MESSAGE_END if we had text content if (hasEmittedTextMessageStart) { - yield { + yield asChunk({ type: 'TEXT_MESSAGE_END', messageId: aguiState.messageId, model: chunk.model || options.model, timestamp, - } + }) } // Emit AG-UI RUN_FINISHED - yield { + yield asChunk({ type: 'RUN_FINISHED', runId: aguiState.runId, threadId: aguiState.threadId, @@ -382,7 +387,7 @@ export class GrokTextAdapter< } : undefined, finishReason: computedFinishReason, - } + }) } } } catch (error: unknown) { @@ -390,7 +395,7 @@ export class GrokTextAdapter< console.log('[Grok Adapter] Stream ended with error:', err.message) // Emit AG-UI RUN_ERROR - yield { + yield asChunk({ type: 'RUN_ERROR', runId: aguiState.runId, model: options.model, @@ -401,7 +406,7 @@ export class GrokTextAdapter< message: err.message || 'Unknown error occurred', code: err.code, }, - } + }) } } diff --git a/packages/typescript/ai-groq/src/adapters/text.ts b/packages/typescript/ai-groq/src/adapters/text.ts index 8d5f588c..835f2811 100644 --- a/packages/typescript/ai-groq/src/adapters/text.ts +++ b/packages/typescript/ai-groq/src/adapters/text.ts @@ -34,6 +34,11 @@ import type { } from '../message-types' import type { GroqClientConfig } from '../utils' +/** Cast an event object to StreamChunk. Adapters construct events with string + * literal types which are structurally compatible with the EventType enum. */ +const asChunk = (chunk: Record) => + chunk as unknown as StreamChunk + /** * Configuration for Groq text adapter */ @@ -94,16 +99,16 @@ export class GroqTextAdapter< if (!aguiState.hasEmittedRunStarted) { aguiState.hasEmittedRunStarted = true - yield { + yield asChunk({ type: 'RUN_STARTED', runId: aguiState.runId, threadId: aguiState.threadId, model: options.model, timestamp, - } + }) } - yield { + yield asChunk({ type: 'RUN_ERROR', runId: aguiState.runId, model: options.model, @@ -114,7 +119,7 @@ export class GroqTextAdapter< message: err.message || 'Unknown error', code: err.code, }, - } + }) console.error('>>> chatStream: Fatal error during response creation <<<') console.error('>>> Error message:', err.message) @@ -222,13 +227,13 @@ export class GroqTextAdapter< if (!aguiState.hasEmittedRunStarted) { aguiState.hasEmittedRunStarted = true - yield { + yield asChunk({ type: 'RUN_STARTED', runId: aguiState.runId, threadId: aguiState.threadId, model: chunk.model || options.model, timestamp, - } + }) } const delta = choice.delta @@ -238,25 +243,25 @@ export class GroqTextAdapter< if (deltaContent) { if (!hasEmittedTextMessageStart) { hasEmittedTextMessageStart = true - yield { + yield asChunk({ type: 'TEXT_MESSAGE_START', messageId: aguiState.messageId, model: chunk.model || options.model, timestamp, role: 'assistant', - } + }) } accumulatedContent += deltaContent - yield { + yield asChunk({ type: 'TEXT_MESSAGE_CONTENT', messageId: aguiState.messageId, model: chunk.model || options.model, timestamp, delta: deltaContent, content: accumulatedContent, - } + }) } if (deltaToolCalls) { @@ -286,7 +291,7 @@ export class GroqTextAdapter< if (toolCall.id && toolCall.name && !toolCall.started) { toolCall.started = true - yield { + yield asChunk({ type: 'TOOL_CALL_START', toolCallId: toolCall.id, toolCallName: toolCall.name, @@ -294,17 +299,17 @@ export class GroqTextAdapter< model: chunk.model || options.model, timestamp, index, - } + }) } if (toolCallDelta.function?.arguments && toolCall.started) { - yield { + yield asChunk({ type: 'TOOL_CALL_ARGS', toolCallId: toolCall.id, model: chunk.model || options.model, timestamp, delta: toolCallDelta.function.arguments, - } + }) } } } @@ -328,7 +333,7 @@ export class GroqTextAdapter< parsedInput = {} } - yield { + yield asChunk({ type: 'TOOL_CALL_END', toolCallId: toolCall.id, toolCallName: toolCall.name, @@ -336,7 +341,7 @@ export class GroqTextAdapter< model: chunk.model || options.model, timestamp, input: parsedInput, - } + }) } } @@ -349,17 +354,17 @@ export class GroqTextAdapter< : 'stop' if (hasEmittedTextMessageStart) { - yield { + yield asChunk({ type: 'TEXT_MESSAGE_END', messageId: aguiState.messageId, model: chunk.model || options.model, timestamp, - } + }) } const groqUsage = chunk.x_groq?.usage - yield { + yield asChunk({ type: 'RUN_FINISHED', runId: aguiState.runId, threadId: aguiState.threadId, @@ -373,14 +378,14 @@ export class GroqTextAdapter< } : undefined, finishReason: computedFinishReason, - } + }) } } } catch (error: unknown) { const err = error as Error & { code?: string } console.log('[Groq Adapter] Stream ended with error:', err.message) - yield { + yield asChunk({ type: 'RUN_ERROR', runId: aguiState.runId, model: options.model, @@ -391,7 +396,7 @@ export class GroqTextAdapter< message: err.message || 'Unknown error occurred', code: err.code, }, - } + }) } } diff --git a/packages/typescript/ai-ollama/src/adapters/summarize.ts b/packages/typescript/ai-ollama/src/adapters/summarize.ts index d4af2055..3b091543 100644 --- a/packages/typescript/ai-ollama/src/adapters/summarize.ts +++ b/packages/typescript/ai-ollama/src/adapters/summarize.ts @@ -14,6 +14,10 @@ import type { SummarizationResult, } from '@tanstack/ai' +/** Cast an event object to StreamChunk. */ +const asChunk = (chunk: Record) => + chunk as unknown as StreamChunk + export type OllamaSummarizeModel = | (typeof OllamaSummarizeModels)[number] | (string & {}) @@ -125,20 +129,20 @@ export class OllamaSummarizeAdapter< for await (const chunk of stream) { if (chunk.response) { accumulatedContent += chunk.response - yield { + yield asChunk({ type: 'TEXT_MESSAGE_CONTENT', messageId: id, model: chunk.model, timestamp: Date.now(), delta: chunk.response, content: accumulatedContent, - } + }) } if (chunk.done) { const promptTokens = estimateTokens(prompt) const completionTokens = estimateTokens(accumulatedContent) - yield { + yield asChunk({ type: 'RUN_FINISHED', runId: id, model: chunk.model, @@ -149,7 +153,7 @@ export class OllamaSummarizeAdapter< completionTokens, totalTokens: promptTokens + completionTokens, }, - } + }) } } } diff --git a/packages/typescript/ai-ollama/src/adapters/text.ts b/packages/typescript/ai-ollama/src/adapters/text.ts index 875b7667..c30afa5c 100644 --- a/packages/typescript/ai-ollama/src/adapters/text.ts +++ b/packages/typescript/ai-ollama/src/adapters/text.ts @@ -21,6 +21,11 @@ import type { } from 'ollama' import type { StreamChunk, TextOptions, Tool } from '@tanstack/ai' +/** Cast an event object to StreamChunk. Adapters construct events with string + * literal types which are structurally compatible with the EventType enum. */ +const asChunk = (chunk: Record) => + chunk as unknown as StreamChunk + export type OllamaTextModel = | (typeof OLLAMA_TEXT_MODELS)[number] | (string & {}) @@ -205,13 +210,13 @@ export class OllamaTextAdapter extends BaseTextAdapter< // Emit RUN_STARTED on first chunk if (!hasEmittedRunStarted) { hasEmittedRunStarted = true - yield { + yield asChunk({ type: 'RUN_STARTED', runId, threadId, model: chunk.model, timestamp, - } + }) } const handleToolCall = (toolCall: ToolCall): Array => { @@ -226,15 +231,17 @@ export class OllamaTextAdapter extends BaseTextAdapter< // Emit TOOL_CALL_START if not already emitted for this tool call if (!toolCallsEmitted.has(toolCallId)) { toolCallsEmitted.add(toolCallId) - events.push({ - type: 'TOOL_CALL_START', - toolCallId, - toolCallName: actualToolCall.function.name || '', - toolName: actualToolCall.function.name || '', - model: chunk.model, - timestamp, - index: actualToolCall.function.index, - }) + events.push( + asChunk({ + type: 'TOOL_CALL_START', + toolCallId, + toolCallName: actualToolCall.function.name || '', + toolName: actualToolCall.function.name || '', + model: chunk.model, + timestamp, + index: actualToolCall.function.index, + }), + ) } // Serialize arguments to a string for the TOOL_CALL_ARGS event @@ -263,15 +270,17 @@ export class OllamaTextAdapter extends BaseTextAdapter< } // Emit TOOL_CALL_END - events.push({ - type: 'TOOL_CALL_END', - toolCallId, - toolCallName: actualToolCall.function.name || '', - toolName: actualToolCall.function.name || '', - model: chunk.model, - timestamp, - input: parsedInput, - }) + events.push( + asChunk({ + type: 'TOOL_CALL_END', + toolCallId, + toolCallName: actualToolCall.function.name || '', + toolName: actualToolCall.function.name || '', + model: chunk.model, + timestamp, + input: parsedInput, + }), + ) return events } @@ -289,31 +298,31 @@ export class OllamaTextAdapter extends BaseTextAdapter< // Close reasoning events if still open if (reasoningMessageId && !hasClosedReasoning) { hasClosedReasoning = true - yield { + yield asChunk({ type: 'REASONING_MESSAGE_END', messageId: reasoningMessageId, model: chunk.model, timestamp, - } - yield { + }) + yield asChunk({ type: 'REASONING_END', messageId: reasoningMessageId, model: chunk.model, timestamp, - } + }) } // Emit TEXT_MESSAGE_END if we had text content if (hasEmittedTextMessageStart) { - yield { + yield asChunk({ type: 'TEXT_MESSAGE_END', messageId, model: chunk.model, timestamp, - } + }) } - yield { + yield asChunk({ type: 'RUN_FINISHED', runId, threadId, @@ -326,7 +335,7 @@ export class OllamaTextAdapter extends BaseTextAdapter< totalTokens: (chunk.prompt_eval_count || 0) + (chunk.eval_count || 0), }, - } + }) continue } @@ -334,41 +343,41 @@ export class OllamaTextAdapter extends BaseTextAdapter< // Close reasoning before text starts if (reasoningMessageId && !hasClosedReasoning) { hasClosedReasoning = true - yield { + yield asChunk({ type: 'REASONING_MESSAGE_END', messageId: reasoningMessageId, model: chunk.model, timestamp, - } - yield { + }) + yield asChunk({ type: 'REASONING_END', messageId: reasoningMessageId, model: chunk.model, timestamp, - } + }) } // Emit TEXT_MESSAGE_START on first text content if (!hasEmittedTextMessageStart) { hasEmittedTextMessageStart = true - yield { + yield asChunk({ type: 'TEXT_MESSAGE_START', messageId, model: chunk.model, timestamp, role: 'assistant', - } + }) } accumulatedContent += chunk.message.content - yield { + yield asChunk({ type: 'TEXT_MESSAGE_CONTENT', messageId, model: chunk.model, timestamp, delta: chunk.message.content, content: accumulatedContent, - } + }) } if (chunk.message.tool_calls && chunk.message.tool_calls.length > 0) { @@ -388,44 +397,44 @@ export class OllamaTextAdapter extends BaseTextAdapter< reasoningMessageId = generateId('msg') // Spec REASONING events - yield { + yield asChunk({ type: 'REASONING_START', messageId: reasoningMessageId, model: chunk.model, timestamp, - } - yield { + }) + yield asChunk({ type: 'REASONING_MESSAGE_START', messageId: reasoningMessageId, role: 'reasoning' as const, model: chunk.model, timestamp, - } + }) // Legacy STEP events (kept during transition) - yield { + yield asChunk({ type: 'STEP_STARTED', stepName: stepId, stepId, model: chunk.model, timestamp, stepType: 'thinking', - } + }) } accumulatedReasoning += chunk.message.thinking // Spec REASONING content event - yield { + yield asChunk({ type: 'REASONING_MESSAGE_CONTENT', messageId: reasoningMessageId!, delta: chunk.message.thinking, model: chunk.model, timestamp, - } + }) // Legacy STEP event - yield { + yield asChunk({ type: 'STEP_FINISHED', stepName: stepId || generateId('step'), stepId: stepId || generateId('step'), @@ -433,7 +442,7 @@ export class OllamaTextAdapter extends BaseTextAdapter< timestamp, delta: chunk.message.thinking, content: accumulatedReasoning, - } + }) } } } diff --git a/packages/typescript/ai-openai/src/adapters/text.ts b/packages/typescript/ai-openai/src/adapters/text.ts index 44d554b4..d63110d0 100644 --- a/packages/typescript/ai-openai/src/adapters/text.ts +++ b/packages/typescript/ai-openai/src/adapters/text.ts @@ -39,6 +39,11 @@ import type { } from '../message-types' import type { OpenAIClientConfig } from '../utils/client' +/** Cast an event object to StreamChunk. Adapters construct events with string + * literal types which are structurally compatible with the EventType enum. */ +const asChunk = (chunk: Record) => + chunk as unknown as StreamChunk + /** * Configuration for OpenAI text adapter */ @@ -274,13 +279,13 @@ export class OpenAITextAdapter< // Emit RUN_STARTED on first chunk if (!hasEmittedRunStarted) { hasEmittedRunStarted = true - yield { + yield asChunk({ type: 'RUN_STARTED', runId, threadId, model: model || options.model, timestamp, - } + }) } const handleContentPart = ( @@ -291,20 +296,20 @@ export class OpenAITextAdapter< ): StreamChunk => { if (contentPart.type === 'output_text') { accumulatedContent += contentPart.text - return { + return asChunk({ type: 'TEXT_MESSAGE_CONTENT', messageId, model: model || options.model, timestamp, delta: contentPart.text, content: accumulatedContent, - } + }) } if (contentPart.type === 'reasoning_text') { accumulatedReasoning += contentPart.text const currentStepId = stepId || genId() - return { + return asChunk({ type: 'STEP_FINISHED', stepName: currentStepId, stepId: currentStepId, @@ -312,9 +317,9 @@ export class OpenAITextAdapter< timestamp, delta: contentPart.text, content: accumulatedReasoning, - } + }) } - return { + return asChunk({ type: 'RUN_ERROR', runId, message: contentPart.refusal, @@ -323,7 +328,7 @@ export class OpenAITextAdapter< error: { message: contentPart.refusal, }, - } + }) } // handle general response events if ( @@ -342,7 +347,7 @@ export class OpenAITextAdapter< accumulatedContent = '' accumulatedReasoning = '' if (chunk.response.error) { - yield { + yield asChunk({ type: 'RUN_ERROR', runId, message: chunk.response.error.message, @@ -350,12 +355,12 @@ export class OpenAITextAdapter< model: chunk.response.model, timestamp, error: chunk.response.error, - } + }) } if (chunk.response.incomplete_details) { const incompleteMessage = chunk.response.incomplete_details.reason ?? '' - yield { + yield asChunk({ type: 'RUN_ERROR', runId, message: incompleteMessage, @@ -364,7 +369,7 @@ export class OpenAITextAdapter< error: { message: incompleteMessage, }, - } + }) } } // Handle output text deltas (token-by-token streaming) @@ -381,42 +386,42 @@ export class OpenAITextAdapter< // Close reasoning events before text starts if (reasoningMessageId && !hasClosedReasoning) { hasClosedReasoning = true - yield { + yield asChunk({ type: 'REASONING_MESSAGE_END', messageId: reasoningMessageId, model: model || options.model, timestamp, - } - yield { + }) + yield asChunk({ type: 'REASONING_END', messageId: reasoningMessageId, model: model || options.model, timestamp, - } + }) } // Emit TEXT_MESSAGE_START on first text content if (!hasEmittedTextMessageStart) { hasEmittedTextMessageStart = true - yield { + yield asChunk({ type: 'TEXT_MESSAGE_START', messageId, model: model || options.model, timestamp, role: 'assistant', - } + }) } accumulatedContent += textDelta hasStreamedContentDeltas = true - yield { + yield asChunk({ type: 'TEXT_MESSAGE_CONTENT', messageId, model: model || options.model, timestamp, delta: textDelta, content: accumulatedContent, - } + }) } } @@ -438,45 +443,45 @@ export class OpenAITextAdapter< reasoningMessageId = genId() // Spec REASONING events - yield { + yield asChunk({ type: 'REASONING_START', messageId: reasoningMessageId, model: model || options.model, timestamp, - } - yield { + }) + yield asChunk({ type: 'REASONING_MESSAGE_START', messageId: reasoningMessageId, role: 'reasoning' as const, model: model || options.model, timestamp, - } + }) // Legacy STEP events (kept during transition) - yield { + yield asChunk({ type: 'STEP_STARTED', stepName: stepId, stepId, model: model || options.model, timestamp, stepType: 'thinking', - } + }) } accumulatedReasoning += reasoningDelta hasStreamedReasoningDeltas = true // Spec REASONING content event - yield { + yield asChunk({ type: 'REASONING_MESSAGE_CONTENT', messageId: reasoningMessageId!, delta: reasoningDelta, model: model || options.model, timestamp, - } + }) // Legacy STEP event - yield { + yield asChunk({ type: 'STEP_FINISHED', stepName: stepId || genId(), stepId: stepId || genId(), @@ -484,7 +489,7 @@ export class OpenAITextAdapter< timestamp, delta: reasoningDelta, content: accumulatedReasoning, - } + }) } } @@ -505,45 +510,45 @@ export class OpenAITextAdapter< reasoningMessageId = genId() // Spec REASONING events - yield { + yield asChunk({ type: 'REASONING_START', messageId: reasoningMessageId, model: model || options.model, timestamp, - } - yield { + }) + yield asChunk({ type: 'REASONING_MESSAGE_START', messageId: reasoningMessageId, role: 'reasoning' as const, model: model || options.model, timestamp, - } + }) // Legacy STEP events (kept during transition) - yield { + yield asChunk({ type: 'STEP_STARTED', stepName: stepId, stepId, model: model || options.model, timestamp, stepType: 'thinking', - } + }) } accumulatedReasoning += summaryDelta hasStreamedReasoningDeltas = true // Spec REASONING content event - yield { + yield asChunk({ type: 'REASONING_MESSAGE_CONTENT', messageId: reasoningMessageId!, delta: summaryDelta, model: model || options.model, timestamp, - } + }) // Legacy STEP event - yield { + yield asChunk({ type: 'STEP_FINISHED', stepName: stepId || genId(), stepId: stepId || genId(), @@ -551,7 +556,7 @@ export class OpenAITextAdapter< timestamp, delta: summaryDelta, content: accumulatedReasoning, - } + }) } } @@ -562,18 +567,18 @@ export class OpenAITextAdapter< if (contentPart.type === 'output_text') { if (reasoningMessageId && !hasClosedReasoning) { hasClosedReasoning = true - yield { + yield asChunk({ type: 'REASONING_MESSAGE_END', messageId: reasoningMessageId, model: model || options.model, timestamp, - } - yield { + }) + yield asChunk({ type: 'REASONING_END', messageId: reasoningMessageId, model: model || options.model, timestamp, - } + }) } } @@ -583,13 +588,13 @@ export class OpenAITextAdapter< !hasEmittedTextMessageStart ) { hasEmittedTextMessageStart = true - yield { + yield asChunk({ type: 'TEXT_MESSAGE_START', messageId, model: model || options.model, timestamp, role: 'assistant', - } + }) } // Emit STEP_STARTED and REASONING events if this is reasoning content if (contentPart.type === 'reasoning_text' && !hasEmittedStepStarted) { @@ -598,29 +603,29 @@ export class OpenAITextAdapter< reasoningMessageId = genId() // Spec REASONING events - yield { + yield asChunk({ type: 'REASONING_START', messageId: reasoningMessageId, model: model || options.model, timestamp, - } - yield { + }) + yield asChunk({ type: 'REASONING_MESSAGE_START', messageId: reasoningMessageId, role: 'reasoning' as const, model: model || options.model, timestamp, - } + }) // Legacy STEP events (kept during transition) - yield { + yield asChunk({ type: 'STEP_STARTED', stepName: stepId, stepId, model: model || options.model, timestamp, stepType: 'thinking', - } + }) } yield handleContentPart(contentPart) } @@ -659,7 +664,7 @@ export class OpenAITextAdapter< }) } // Emit TOOL_CALL_START - yield { + yield asChunk({ type: 'TOOL_CALL_START', toolCallId: item.id, toolCallName: item.name || '', @@ -667,7 +672,7 @@ export class OpenAITextAdapter< model: model || options.model, timestamp, index: chunk.output_index, - } + }) toolCallMetadata.get(item.id)!.started = true } } @@ -678,14 +683,14 @@ export class OpenAITextAdapter< chunk.delta ) { const metadata = toolCallMetadata.get(chunk.item_id) - yield { + yield asChunk({ type: 'TOOL_CALL_ARGS', toolCallId: chunk.item_id, model: model || options.model, timestamp, delta: chunk.delta, args: metadata ? undefined : chunk.delta, // We don't accumulate here, let caller handle it - } + }) } if (chunk.type === 'response.function_call_arguments.done') { @@ -703,7 +708,7 @@ export class OpenAITextAdapter< parsedInput = {} } - yield { + yield asChunk({ type: 'TOOL_CALL_END', toolCallId: item_id, toolCallName: name, @@ -711,35 +716,35 @@ export class OpenAITextAdapter< model: model || options.model, timestamp, input: parsedInput, - } + }) } if (chunk.type === 'response.completed') { // Close reasoning events if still open if (reasoningMessageId && !hasClosedReasoning) { hasClosedReasoning = true - yield { + yield asChunk({ type: 'REASONING_MESSAGE_END', messageId: reasoningMessageId, model: model || options.model, timestamp, - } - yield { + }) + yield asChunk({ type: 'REASONING_END', messageId: reasoningMessageId, model: model || options.model, timestamp, - } + }) } // Emit TEXT_MESSAGE_END if we had text content if (hasEmittedTextMessageStart) { - yield { + yield asChunk({ type: 'TEXT_MESSAGE_END', messageId, model: model || options.model, timestamp, - } + }) } // Determine finish reason based on output @@ -749,7 +754,7 @@ export class OpenAITextAdapter< (item as { type: string }).type === 'function_call', ) - yield { + yield asChunk({ type: 'RUN_FINISHED', runId, threadId, @@ -761,11 +766,11 @@ export class OpenAITextAdapter< totalTokens: chunk.response.usage?.total_tokens || 0, }, finishReason: hasFunctionCalls ? 'tool_calls' : 'stop', - } + }) } if (chunk.type === 'error') { - yield { + yield asChunk({ type: 'RUN_ERROR', runId, message: chunk.message, @@ -776,7 +781,7 @@ export class OpenAITextAdapter< message: chunk.message, code: chunk.code ?? undefined, }, - } + }) } } } catch (error: unknown) { @@ -788,7 +793,7 @@ export class OpenAITextAdapter< error: err.message, }, ) - yield { + yield asChunk({ type: 'RUN_ERROR', runId, message: err.message || 'Unknown error occurred', @@ -799,7 +804,7 @@ export class OpenAITextAdapter< message: err.message || 'Unknown error occurred', code: err.code, }, - } + }) } } diff --git a/packages/typescript/ai-openrouter/src/adapters/summarize.ts b/packages/typescript/ai-openrouter/src/adapters/summarize.ts index faa4d2e2..7494a8e5 100644 --- a/packages/typescript/ai-openrouter/src/adapters/summarize.ts +++ b/packages/typescript/ai-openrouter/src/adapters/summarize.ts @@ -87,7 +87,7 @@ export class OpenRouterSummarizeAdapter< } // AG-UI RUN_ERROR event if (chunk.type === 'RUN_ERROR') { - throw new Error(`Error during summarization: ${chunk.error.message}`) + throw new Error(`Error during summarization: ${chunk.error?.message}`) } } diff --git a/packages/typescript/ai-openrouter/src/adapters/text.ts b/packages/typescript/ai-openrouter/src/adapters/text.ts index fa1d82f4..b80482e5 100644 --- a/packages/typescript/ai-openrouter/src/adapters/text.ts +++ b/packages/typescript/ai-openrouter/src/adapters/text.ts @@ -38,6 +38,11 @@ import type { Message, } from '@openrouter/sdk/models' +/** Cast an event object to StreamChunk. Adapters construct events with string + * literal types which are structurally compatible with the EventType enum. */ +const asChunk = (chunk: Record) => + chunk as unknown as StreamChunk + export interface OpenRouterConfig extends SDKOptions {} export type OpenRouterTextModels = (typeof OPENROUTER_CHAT_MODELS)[number] @@ -128,18 +133,18 @@ export class OpenRouterTextAdapter< // Emit RUN_STARTED on first chunk if (!aguiState.hasEmittedRunStarted) { aguiState.hasEmittedRunStarted = true - yield { + yield asChunk({ type: 'RUN_STARTED', runId: aguiState.runId, threadId: aguiState.threadId, model: currentModel || options.model, timestamp, - } + }) } if (chunk.error) { // Emit AG-UI RUN_ERROR - yield { + yield asChunk({ type: 'RUN_ERROR', runId: aguiState.runId, model: currentModel || options.model, @@ -150,7 +155,7 @@ export class OpenRouterTextAdapter< message: chunk.error.message || 'Unknown error', code: String(chunk.error.code), }, - } + }) continue } @@ -177,18 +182,18 @@ export class OpenRouterTextAdapter< // Emit RUN_STARTED if not yet emitted (error on first call) if (!aguiState.hasEmittedRunStarted) { aguiState.hasEmittedRunStarted = true - yield { + yield asChunk({ type: 'RUN_STARTED', runId: aguiState.runId, threadId: aguiState.threadId, model: options.model, timestamp, - } + }) } if (error instanceof RequestAbortedError) { // Emit AG-UI RUN_ERROR - yield { + yield asChunk({ type: 'RUN_ERROR', runId: aguiState.runId, model: options.model, @@ -199,12 +204,12 @@ export class OpenRouterTextAdapter< message: 'Request aborted', code: 'aborted', }, - } + }) return } // Emit AG-UI RUN_ERROR - yield { + yield asChunk({ type: 'RUN_ERROR', runId: aguiState.runId, model: options.model, @@ -213,7 +218,7 @@ export class OpenRouterTextAdapter< error: { message: (error as Error).message || 'Unknown error', }, - } + }) } } @@ -304,44 +309,44 @@ export class OpenRouterTextAdapter< // Close reasoning before text starts if (aguiState.reasoningMessageId && !aguiState.hasClosedReasoning) { aguiState.hasClosedReasoning = true - yield { + yield asChunk({ type: 'REASONING_MESSAGE_END', messageId: aguiState.reasoningMessageId, model: meta.model, timestamp: meta.timestamp, - } - yield { + }) + yield asChunk({ type: 'REASONING_END', messageId: aguiState.reasoningMessageId, model: meta.model, timestamp: meta.timestamp, - } + }) } // Emit TEXT_MESSAGE_START on first text content if (!aguiState.hasEmittedTextMessageStart) { aguiState.hasEmittedTextMessageStart = true - yield { + yield asChunk({ type: 'TEXT_MESSAGE_START', messageId: aguiState.messageId, model: meta.model, timestamp: meta.timestamp, role: 'assistant', - } + }) } accumulated.content += delta.content updateAccumulated(accumulated.reasoning, accumulated.content) // Emit AG-UI TEXT_MESSAGE_CONTENT - yield { + yield asChunk({ type: 'TEXT_MESSAGE_CONTENT', messageId: aguiState.messageId, model: meta.model, timestamp: meta.timestamp, delta: delta.content, content: accumulated.content, - } + }) } if (delta.reasoningDetails) { @@ -356,45 +361,45 @@ export class OpenRouterTextAdapter< aguiState.reasoningMessageId = this.generateId() // Spec REASONING events - yield { + yield asChunk({ type: 'REASONING_START', messageId: aguiState.reasoningMessageId, model: meta.model, timestamp: meta.timestamp, - } - yield { + }) + yield asChunk({ type: 'REASONING_MESSAGE_START', messageId: aguiState.reasoningMessageId, role: 'reasoning' as const, model: meta.model, timestamp: meta.timestamp, - } + }) // Legacy STEP events (kept during transition) - yield { + yield asChunk({ type: 'STEP_STARTED', stepName: aguiState.stepId, stepId: aguiState.stepId, model: meta.model, timestamp: meta.timestamp, stepType: 'thinking', - } + }) } accumulated.reasoning += text updateAccumulated(accumulated.reasoning, accumulated.content) // Spec REASONING content event - yield { + yield asChunk({ type: 'REASONING_MESSAGE_CONTENT', messageId: aguiState.reasoningMessageId!, delta: text, model: meta.model, timestamp: meta.timestamp, - } + }) // Legacy STEP event - yield { + yield asChunk({ type: 'STEP_FINISHED', stepName: aguiState.stepId!, stepId: aguiState.stepId!, @@ -402,7 +407,7 @@ export class OpenRouterTextAdapter< timestamp: meta.timestamp, delta: text, content: accumulated.reasoning, - } + }) continue } if (detail.type === 'reasoning.summary') { @@ -415,45 +420,45 @@ export class OpenRouterTextAdapter< aguiState.reasoningMessageId = this.generateId() // Spec REASONING events - yield { + yield asChunk({ type: 'REASONING_START', messageId: aguiState.reasoningMessageId, model: meta.model, timestamp: meta.timestamp, - } - yield { + }) + yield asChunk({ type: 'REASONING_MESSAGE_START', messageId: aguiState.reasoningMessageId, role: 'reasoning' as const, model: meta.model, timestamp: meta.timestamp, - } + }) // Legacy STEP events (kept during transition) - yield { + yield asChunk({ type: 'STEP_STARTED', stepName: aguiState.stepId, stepId: aguiState.stepId, model: meta.model, timestamp: meta.timestamp, stepType: 'thinking', - } + }) } accumulated.reasoning += text updateAccumulated(accumulated.reasoning, accumulated.content) // Spec REASONING content event - yield { + yield asChunk({ type: 'REASONING_MESSAGE_CONTENT', messageId: aguiState.reasoningMessageId!, delta: text, model: meta.model, timestamp: meta.timestamp, - } + }) // Legacy STEP event - yield { + yield asChunk({ type: 'STEP_FINISHED', stepName: aguiState.stepId!, stepId: aguiState.stepId!, @@ -461,7 +466,7 @@ export class OpenRouterTextAdapter< timestamp: meta.timestamp, delta: text, content: accumulated.reasoning, - } + }) continue } } @@ -492,7 +497,7 @@ export class OpenRouterTextAdapter< // Emit TOOL_CALL_START when we have id and name if (buffer.id && buffer.name && !buffer.started) { buffer.started = true - yield { + yield asChunk({ type: 'TOOL_CALL_START', toolCallId: buffer.id, toolCallName: buffer.name, @@ -500,25 +505,25 @@ export class OpenRouterTextAdapter< model: meta.model, timestamp: meta.timestamp, index: tc.index, - } + }) } // Emit TOOL_CALL_ARGS for argument deltas if (tc.function?.arguments && buffer.started) { - yield { + yield asChunk({ type: 'TOOL_CALL_ARGS', toolCallId: buffer.id, model: meta.model, timestamp: meta.timestamp, delta: tc.function.arguments, - } + }) } } } if (delta.refusal) { // Emit AG-UI RUN_ERROR for refusal - yield { + yield asChunk({ type: 'RUN_ERROR', runId: aguiState.runId, model: meta.model, @@ -526,7 +531,7 @@ export class OpenRouterTextAdapter< message: delta.refusal, code: 'refusal', error: { message: delta.refusal, code: 'refusal' }, - } + }) } if (finishReason) { @@ -542,7 +547,7 @@ export class OpenRouterTextAdapter< } // Emit AG-UI TOOL_CALL_END - yield { + yield asChunk({ type: 'TOOL_CALL_END', toolCallId: tc.id, toolCallName: tc.name, @@ -550,7 +555,7 @@ export class OpenRouterTextAdapter< model: meta.model, timestamp: meta.timestamp, input: parsedInput, - } + }) } toolCallBuffers.clear() @@ -566,32 +571,32 @@ export class OpenRouterTextAdapter< // Close reasoning events if still open if (aguiState.reasoningMessageId && !aguiState.hasClosedReasoning) { aguiState.hasClosedReasoning = true - yield { + yield asChunk({ type: 'REASONING_MESSAGE_END', messageId: aguiState.reasoningMessageId, model: meta.model, timestamp: meta.timestamp, - } - yield { + }) + yield asChunk({ type: 'REASONING_END', messageId: aguiState.reasoningMessageId, model: meta.model, timestamp: meta.timestamp, - } + }) } // Emit TEXT_MESSAGE_END if we had text content if (aguiState.hasEmittedTextMessageStart) { - yield { + yield asChunk({ type: 'TEXT_MESSAGE_END', messageId: aguiState.messageId, model: meta.model, timestamp: meta.timestamp, - } + }) } // Emit AG-UI RUN_FINISHED - yield { + yield asChunk({ type: 'RUN_FINISHED', runId: aguiState.runId, threadId: aguiState.threadId, @@ -605,7 +610,7 @@ export class OpenRouterTextAdapter< } : undefined, finishReason: computedFinishReason, - } + }) } } diff --git a/packages/typescript/ai-openrouter/tests/openrouter-adapter.test.ts b/packages/typescript/ai-openrouter/tests/openrouter-adapter.test.ts index efb0b9af..b636ac96 100644 --- a/packages/typescript/ai-openrouter/tests/openrouter-adapter.test.ts +++ b/packages/typescript/ai-openrouter/tests/openrouter-adapter.test.ts @@ -381,7 +381,7 @@ describe('OpenRouter adapter option mapping', () => { expect(errorChunk).toBeDefined() if (errorChunk && errorChunk.type === 'RUN_ERROR') { - expect(errorChunk.error.message).toBe('Invalid API key') + expect(errorChunk.error?.message).toBe('Invalid API key') } }) }) @@ -674,7 +674,7 @@ describe('OpenRouter AG-UI event emission', () => { const runErrorChunk = chunks.find((c) => c.type === 'RUN_ERROR') expect(runErrorChunk).toBeDefined() if (runErrorChunk?.type === 'RUN_ERROR') { - expect(runErrorChunk.error.message).toBe('API key invalid') + expect(runErrorChunk.error?.message).toBe('API key invalid') } }) @@ -725,14 +725,14 @@ describe('OpenRouter AG-UI event emission', () => { expect(eventTypes[0]).toBe('RUN_STARTED') // Should have TEXT_MESSAGE_START before TEXT_MESSAGE_CONTENT - const textStartIndex = eventTypes.indexOf('TEXT_MESSAGE_START') - const textContentIndex = eventTypes.indexOf('TEXT_MESSAGE_CONTENT') + const textStartIndex = eventTypes.indexOf('TEXT_MESSAGE_START' as any) + const textContentIndex = eventTypes.indexOf('TEXT_MESSAGE_CONTENT' as any) expect(textStartIndex).toBeGreaterThan(-1) expect(textContentIndex).toBeGreaterThan(textStartIndex) // Should have TEXT_MESSAGE_END before RUN_FINISHED - const textEndIndex = eventTypes.indexOf('TEXT_MESSAGE_END') - const runFinishedIndex = eventTypes.indexOf('RUN_FINISHED') + const textEndIndex = eventTypes.indexOf('TEXT_MESSAGE_END' as any) + const runFinishedIndex = eventTypes.indexOf('RUN_FINISHED' as any) expect(textEndIndex).toBeGreaterThan(-1) expect(runFinishedIndex).toBeGreaterThan(textEndIndex) diff --git a/packages/typescript/ai/src/activities/chat/stream/processor.ts b/packages/typescript/ai/src/activities/chat/stream/processor.ts index 22e74d35..cec48e40 100644 --- a/packages/typescript/ai/src/activities/chat/stream/processor.ts +++ b/packages/typescript/ai/src/activities/chat/stream/processor.ts @@ -755,7 +755,7 @@ export class StreamProcessor { ): void { this.resetStreamState() // AG-UI Message[] is compatible with UIMessage[] at runtime - this.messages = [...chunk.messages] as unknown as UIMessage[] + this.messages = [...chunk.messages] as unknown as Array this.emitMessagesChange() } @@ -865,8 +865,7 @@ export class StreamProcessor { // New tool call starting const initialState: ToolCallState = 'awaiting-input' - // Prefer spec field `toolCallName`; fall back to deprecated `toolName` - const toolName = chunk.toolCallName ?? (chunk as any).toolName + const toolName = chunk.toolCallName const newToolCall: InternalToolCallState = { id: chunk.toolCallId, @@ -1226,7 +1225,7 @@ export class StreamProcessor { if (this.events.onCustomEvent) { const toolCallId = chunk.value && typeof chunk.value === 'object' - ? (chunk.value as any).toolCallId + ? chunk.value.toolCallId : undefined this.events.onCustomEvent(chunk.name, chunk.value, { toolCallId }) } diff --git a/packages/typescript/ai/src/activities/chat/tools/tool-calls.ts b/packages/typescript/ai/src/activities/chat/tools/tool-calls.ts index 5be684a2..d55f2b5a 100644 --- a/packages/typescript/ai/src/activities/chat/tools/tool-calls.ts +++ b/packages/typescript/ai/src/activities/chat/tools/tool-calls.ts @@ -93,8 +93,7 @@ export class ToolCallManager { */ addToolCallStartEvent(event: ToolCallStartEvent): void { const index = event.index ?? this.toolCallsMap.size - // Prefer spec field `toolCallName`; fall back to deprecated `toolName` - const name = event.toolCallName ?? event.toolName + const name = event.toolCallName this.toolCallsMap.set(index, { id: event.toolCallId, type: 'function', diff --git a/packages/typescript/ai/src/types.ts b/packages/typescript/ai/src/types.ts index 3f0c285f..a17793f1 100644 --- a/packages/typescript/ai/src/types.ts +++ b/packages/typescript/ai/src/types.ts @@ -1,29 +1,29 @@ import type { StandardJSONSchemaV1 } from '@standard-schema/spec' import type { - EventType, BaseEvent as AGUIBaseEvent, - RunStartedEvent as AGUIRunStartedEvent, - RunFinishedEvent as AGUIRunFinishedEvent, + CustomEvent as AGUICustomEvent, + MessagesSnapshotEvent as AGUIMessagesSnapshotEvent, + ReasoningEncryptedValueEvent as AGUIReasoningEncryptedValueEvent, + ReasoningEndEvent as AGUIReasoningEndEvent, + ReasoningMessageContentEvent as AGUIReasoningMessageContentEvent, + ReasoningMessageEndEvent as AGUIReasoningMessageEndEvent, + ReasoningMessageStartEvent as AGUIReasoningMessageStartEvent, + ReasoningStartEvent as AGUIReasoningStartEvent, RunErrorEvent as AGUIRunErrorEvent, - TextMessageStartEvent as AGUITextMessageStartEvent, + RunFinishedEvent as AGUIRunFinishedEvent, + RunStartedEvent as AGUIRunStartedEvent, + StateDeltaEvent as AGUIStateDeltaEvent, + StateSnapshotEvent as AGUIStateSnapshotEvent, + StepFinishedEvent as AGUIStepFinishedEvent, + StepStartedEvent as AGUIStepStartedEvent, TextMessageContentEvent as AGUITextMessageContentEvent, TextMessageEndEvent as AGUITextMessageEndEvent, - ToolCallStartEvent as AGUIToolCallStartEvent, + TextMessageStartEvent as AGUITextMessageStartEvent, ToolCallArgsEvent as AGUIToolCallArgsEvent, ToolCallEndEvent as AGUIToolCallEndEvent, ToolCallResultEvent as AGUIToolCallResultEvent, - StepStartedEvent as AGUIStepStartedEvent, - StepFinishedEvent as AGUIStepFinishedEvent, - MessagesSnapshotEvent as AGUIMessagesSnapshotEvent, - StateSnapshotEvent as AGUIStateSnapshotEvent, - StateDeltaEvent as AGUIStateDeltaEvent, - CustomEvent as AGUICustomEvent, - ReasoningStartEvent as AGUIReasoningStartEvent, - ReasoningMessageStartEvent as AGUIReasoningMessageStartEvent, - ReasoningMessageContentEvent as AGUIReasoningMessageContentEvent, - ReasoningMessageEndEvent as AGUIReasoningMessageEndEvent, - ReasoningEndEvent as AGUIReasoningEndEvent, - ReasoningEncryptedValueEvent as AGUIReasoningEncryptedValueEvent, + ToolCallStartEvent as AGUIToolCallStartEvent, + EventType, } from '@ag-ui/core' /** diff --git a/packages/typescript/ai/tests/chat.test.ts b/packages/typescript/ai/tests/chat.test.ts index d9d4bdbf..b529da71 100644 --- a/packages/typescript/ai/tests/chat.test.ts +++ b/packages/typescript/ai/tests/chat.test.ts @@ -1377,9 +1377,7 @@ describe('chat()', () => { const chunks = await collectChunks(stream as AsyncIterable) - const toolStartChunks = chunks.filter( - (c) => c.type === 'TOOL_CALL_START', - ) + const toolStartChunks = chunks.filter((c) => c.type === 'TOOL_CALL_START') for (const chunk of toolStartChunks) { // toolCallName should be present (spec field) expect((chunk as any).toolCallName).toBe('get_weather') @@ -1444,9 +1442,7 @@ describe('chat()', () => { const chunks = await collectChunks(stream as AsyncIterable) - const resultChunks = chunks.filter( - (c) => c.type === 'TOOL_CALL_RESULT', - ) + const resultChunks = chunks.filter((c) => c.type === 'TOOL_CALL_RESULT') expect(resultChunks.length).toBeGreaterThanOrEqual(1) expect((resultChunks[0] as any).toolCallId).toBe('tc-1') expect((resultChunks[0] as any).content).toContain('72') diff --git a/packages/typescript/ai/tests/custom-events-integration.test.ts b/packages/typescript/ai/tests/custom-events-integration.test.ts index 73137293..36ecdae8 100644 --- a/packages/typescript/ai/tests/custom-events-integration.test.ts +++ b/packages/typescript/ai/tests/custom-events-integration.test.ts @@ -65,28 +65,34 @@ describe('Custom Events Integration', () => { processor.prepareAssistantMessage() // Simulate tool call sequence - processor.processChunk(sc({ - type: 'TOOL_CALL_START', - toolCallId: 'tc-1', - toolName: 'testTool', - timestamp: Date.now(), - index: 0, - })) - - processor.processChunk(sc({ - type: 'TOOL_CALL_ARGS', - toolCallId: 'tc-1', - timestamp: Date.now(), - delta: '{"message": "Hello World"}', - })) - - processor.processChunk(sc({ - type: 'TOOL_CALL_END', - toolCallId: 'tc-1', - toolName: 'testTool', - timestamp: Date.now(), - input: { message: 'Hello World' }, - })) + processor.processChunk( + sc({ + type: 'TOOL_CALL_START', + toolCallId: 'tc-1', + toolName: 'testTool', + timestamp: Date.now(), + index: 0, + }), + ) + + processor.processChunk( + sc({ + type: 'TOOL_CALL_ARGS', + toolCallId: 'tc-1', + timestamp: Date.now(), + delta: '{"message": "Hello World"}', + }), + ) + + processor.processChunk( + sc({ + type: 'TOOL_CALL_END', + toolCallId: 'tc-1', + toolName: 'testTool', + timestamp: Date.now(), + input: { message: 'Hello World' }, + }), + ) // Execute the tool manually (simulating what happens in real scenario) const toolExecuteFunc = (testTool as any).execute @@ -95,12 +101,14 @@ describe('Custom Events Integration', () => { toolCallId: 'tc-1', emitCustomEvent: (eventName: string, data: any) => { // This simulates the real behavior where emitCustomEvent creates CUSTOM stream chunks - processor.processChunk(sc({ - type: 'CUSTOM', - name: eventName, - value: { ...data, toolCallId: 'tc-1' }, - timestamp: Date.now(), - })) + processor.processChunk( + sc({ + type: 'CUSTOM', + name: eventName, + value: { ...data, toolCallId: 'tc-1' }, + timestamp: Date.now(), + }), + ) }, } @@ -161,12 +169,14 @@ describe('Custom Events Integration', () => { }) // Emit custom event without toolCallId - processor.processChunk(sc({ - type: 'CUSTOM', - name: 'system:status', - value: { status: 'ready', version: '1.0.0' }, - timestamp: Date.now(), - })) + processor.processChunk( + sc({ + type: 'CUSTOM', + name: 'system:status', + value: { status: 'ready', version: '1.0.0' }, + timestamp: Date.now(), + }), + ) expect(onCustomEvent).toHaveBeenCalledWith( 'system:status', @@ -198,37 +208,43 @@ describe('Custom Events Integration', () => { processor.prepareAssistantMessage() // System event: tool-input-available - processor.processChunk(sc({ - type: 'CUSTOM', - name: 'tool-input-available', - value: { - toolCallId: 'tc-1', - toolName: 'testTool', - input: { test: true }, - }, - timestamp: Date.now(), - })) + processor.processChunk( + sc({ + type: 'CUSTOM', + name: 'tool-input-available', + value: { + toolCallId: 'tc-1', + toolName: 'testTool', + input: { test: true }, + }, + timestamp: Date.now(), + }), + ) // System event: approval-requested - processor.processChunk(sc({ - type: 'CUSTOM', - name: 'approval-requested', - value: { - toolCallId: 'tc-2', - toolName: 'dangerousTool', - input: { action: 'delete' }, - approval: { id: 'approval-1', needsApproval: true }, - }, - timestamp: Date.now(), - })) + processor.processChunk( + sc({ + type: 'CUSTOM', + name: 'approval-requested', + value: { + toolCallId: 'tc-2', + toolName: 'dangerousTool', + input: { action: 'delete' }, + approval: { id: 'approval-1', needsApproval: true }, + }, + timestamp: Date.now(), + }), + ) // Custom event (should be forwarded) - processor.processChunk(sc({ - type: 'CUSTOM', - name: 'user:custom-event', - value: { message: 'This should be forwarded' }, - timestamp: Date.now(), - })) + processor.processChunk( + sc({ + type: 'CUSTOM', + name: 'user:custom-event', + value: { message: 'This should be forwarded' }, + timestamp: Date.now(), + }), + ) // System events should trigger their specific handlers, not onCustomEvent expect(onToolCall).toHaveBeenCalledTimes(1) diff --git a/packages/typescript/ai/tests/tool-call-manager.test.ts b/packages/typescript/ai/tests/tool-call-manager.test.ts index c36eb45c..5927e168 100644 --- a/packages/typescript/ai/tests/tool-call-manager.test.ts +++ b/packages/typescript/ai/tests/tool-call-manager.test.ts @@ -87,7 +87,7 @@ describe('ToolCallManager', () => { manager.addToolCallStartEvent( toolCallStart({ toolCallId: 'call_123', - toolName: 'get_weather', + toolCallName: 'get_weather', index: 0, }), ) @@ -120,7 +120,7 @@ describe('ToolCallManager', () => { manager.addToolCallStartEvent( toolCallStart({ toolCallId: 'call_123', - toolName: 'get_weather', + toolCallName: 'get_weather', index: 0, }), ) @@ -136,7 +136,7 @@ describe('ToolCallManager', () => { manager.addToolCallStartEvent( toolCallStart({ toolCallId: 'call_456', - toolName: '', + toolCallName: '', index: 1, }), ) @@ -152,7 +152,7 @@ describe('ToolCallManager', () => { manager.addToolCallStartEvent( toolCallStart({ toolCallId: 'call_123', - toolName: 'get_weather', + toolCallName: 'get_weather', index: 0, }), ) @@ -197,7 +197,7 @@ describe('ToolCallManager', () => { manager.addToolCallStartEvent( toolCallStart({ toolCallId: 'call_123', - toolName: 'error_tool', + toolCallName: 'error_tool', index: 0, }), ) @@ -237,7 +237,7 @@ describe('ToolCallManager', () => { manager.addToolCallStartEvent( toolCallStart({ toolCallId: 'call_123', - toolName: 'no_execute', + toolCallName: 'no_execute', index: 0, }), ) @@ -266,7 +266,7 @@ describe('ToolCallManager', () => { manager.addToolCallStartEvent( toolCallStart({ toolCallId: 'call_123', - toolName: 'get_weather', + toolCallName: 'get_weather', index: 0, }), ) @@ -297,7 +297,7 @@ describe('ToolCallManager', () => { manager.addToolCallStartEvent( toolCallStart({ toolCallId: 'call_weather', - toolName: 'get_weather', + toolCallName: 'get_weather', index: 0, }), ) @@ -312,7 +312,7 @@ describe('ToolCallManager', () => { manager.addToolCallStartEvent( toolCallStart({ toolCallId: 'call_calc', - toolName: 'calculate', + toolCallName: 'calculate', index: 1, }), ) @@ -349,7 +349,7 @@ describe('ToolCallManager', () => { manager.addToolCallStartEvent( toolCallStart({ toolCallId: 'call_123', - toolName: 'get_weather', + toolCallName: 'get_weather', index: 0, }), ) @@ -367,7 +367,7 @@ describe('ToolCallManager', () => { manager.addToolCallStartEvent( toolCallStart({ toolCallId: 'call_123', - toolName: 'get_weather', + toolCallName: 'get_weather', index: 0, }), ) @@ -397,7 +397,7 @@ describe('ToolCallManager', () => { manager.addToolCallStartEvent( toolCallStart({ toolCallId: 'call_123', - toolName: 'get_weather', + toolCallName: 'get_weather', index: 0, }), ) @@ -405,7 +405,7 @@ describe('ToolCallManager', () => { manager.completeToolCall( toolCallEnd({ toolCallId: 'call_123', - toolName: 'get_weather', + toolCallName: 'get_weather', input: { location: 'New York' }, }), ) From 95c2ecb7fff6326db338d4f17aa032e24cf4d5e3 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 1 Apr 2026 12:12:13 +0200 Subject: [PATCH 12/30] fix: resolve eslint and test failures from strip-to-spec middleware Remove assertions for fields (content, finishReason, usage) that the stripToSpec middleware now strips from emitted events. Fix unnecessary nullish coalescing in ai-openai and add type casts in ai-vue tests. --- .../tests/anthropic-adapter.test.ts | 3 +-- .../ai-gemini/tests/gemini-adapter.test.ts | 18 +++++++----------- .../typescript/ai-openai/src/adapters/text.ts | 2 +- .../tests/openrouter-adapter.test.ts | 8 -------- .../ai-vue/tests/use-generation.test.ts | 6 +++--- 5 files changed, 12 insertions(+), 25 deletions(-) diff --git a/packages/typescript/ai-anthropic/tests/anthropic-adapter.test.ts b/packages/typescript/ai-anthropic/tests/anthropic-adapter.test.ts index 76c50ccf..a1bcd593 100644 --- a/packages/typescript/ai-anthropic/tests/anthropic-adapter.test.ts +++ b/packages/typescript/ai-anthropic/tests/anthropic-adapter.test.ts @@ -616,7 +616,6 @@ describe('Anthropic stream processing', () => { expect(runFinished).toHaveLength(1) expect(runFinished[0]).toMatchObject({ type: 'RUN_FINISHED', - finishReason: 'stop', }) }) @@ -684,7 +683,7 @@ describe('Anthropic stream processing', () => { const runFinished = chunks.filter((c) => c.type === 'RUN_FINISHED') expect(runFinished).toHaveLength(1) expect(runFinished[0]).toMatchObject({ - finishReason: 'tool_calls', + type: 'RUN_FINISHED', }) }) }) diff --git a/packages/typescript/ai-gemini/tests/gemini-adapter.test.ts b/packages/typescript/ai-gemini/tests/gemini-adapter.test.ts index 8f1aabef..653b8643 100644 --- a/packages/typescript/ai-gemini/tests/gemini-adapter.test.ts +++ b/packages/typescript/ai-gemini/tests/gemini-adapter.test.ts @@ -306,27 +306,23 @@ describe('GeminiAdapter through AI', () => { type: 'TEXT_MESSAGE_START', role: 'assistant', }) - expect(received[2]).toMatchObject({ + const contentChunks = received.filter( + (c) => c.type === 'TEXT_MESSAGE_CONTENT', + ) + expect(contentChunks).toHaveLength(2) + expect(contentChunks[0]).toMatchObject({ type: 'TEXT_MESSAGE_CONTENT', delta: 'Partly ', - content: 'Partly ', }) - expect(received[3]).toMatchObject({ + expect(contentChunks[1]).toMatchObject({ type: 'TEXT_MESSAGE_CONTENT', delta: 'cloudy', - content: 'Partly cloudy', }) - expect(received[4]).toMatchObject({ + expect(received.find((c) => c.type === 'TEXT_MESSAGE_END')).toMatchObject({ type: 'TEXT_MESSAGE_END', }) expect(received.at(-1)).toMatchObject({ type: 'RUN_FINISHED', - finishReason: 'stop', - usage: { - promptTokens: 4, - completionTokens: 2, - totalTokens: 6, - }, }) }) diff --git a/packages/typescript/ai-openai/src/adapters/text.ts b/packages/typescript/ai-openai/src/adapters/text.ts index d63110d0..3c7df5c0 100644 --- a/packages/typescript/ai-openai/src/adapters/text.ts +++ b/packages/typescript/ai-openai/src/adapters/text.ts @@ -351,7 +351,7 @@ export class OpenAITextAdapter< type: 'RUN_ERROR', runId, message: chunk.response.error.message, - code: chunk.response.error.code ?? undefined, + code: chunk.response.error.code, model: chunk.response.model, timestamp, error: chunk.response.error, diff --git a/packages/typescript/ai-openrouter/tests/openrouter-adapter.test.ts b/packages/typescript/ai-openrouter/tests/openrouter-adapter.test.ts index b636ac96..3c28682f 100644 --- a/packages/typescript/ai-openrouter/tests/openrouter-adapter.test.ts +++ b/packages/typescript/ai-openrouter/tests/openrouter-adapter.test.ts @@ -208,24 +208,16 @@ describe('OpenRouter adapter option mapping', () => { expect(contentChunks[0]).toMatchObject({ type: 'TEXT_MESSAGE_CONTENT', delta: 'Hello ', - content: 'Hello ', }) expect(contentChunks[1]).toMatchObject({ type: 'TEXT_MESSAGE_CONTENT', delta: 'world', - content: 'Hello world', }) const runFinishedChunk = chunks.find((c) => c.type === 'RUN_FINISHED') expect(runFinishedChunk).toMatchObject({ type: 'RUN_FINISHED', - finishReason: 'stop', - usage: { - promptTokens: 5, - completionTokens: 2, - totalTokens: 7, - }, }) }) diff --git a/packages/typescript/ai-vue/tests/use-generation.test.ts b/packages/typescript/ai-vue/tests/use-generation.test.ts index 9fae2a69..22afc2cb 100644 --- a/packages/typescript/ai-vue/tests/use-generation.test.ts +++ b/packages/typescript/ai-vue/tests/use-generation.test.ts @@ -26,7 +26,7 @@ function createGenerationChunks(result: unknown): Array { finishReason: 'stop', timestamp: Date.now(), }, - ] + ] as unknown as Array } // Helper to create video generation stream chunks @@ -57,7 +57,7 @@ function createVideoChunks(jobId: string, url: string): Array { finishReason: 'stop', timestamp: Date.now(), }, - ] + ] as unknown as Array } // Helper to create error stream chunks @@ -70,7 +70,7 @@ function createErrorChunks(message: string): Array { error: { message }, timestamp: Date.now(), }, - ] + ] as unknown as Array } /** From e8bdbbfa04a032030e540704097db1ead2deec42 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 1 Apr 2026 13:09:37 +0200 Subject: [PATCH 13/30] fix: honor caller runId, prevent duplicate thinking, add error IDs, fix reasoning ordering - Honor caller-provided runId/threadId in all 7 adapters using ?? fallback - Prevent duplicate thinking content from dual STEP_FINISHED/REASONING_MESSAGE_CONTENT events - Assert exact threadId value in chat test instead of just toBeDefined - Add runId/threadId to RUN_ERROR in generateVideo and stream-generation-result - Move reasoning processing before content processing in OpenRouter adapter --- .../ai-anthropic/src/adapters/text.ts | 4 +- .../typescript/ai-gemini/src/adapters/text.ts | 4 +- .../typescript/ai-grok/src/adapters/text.ts | 4 +- .../typescript/ai-groq/src/adapters/text.ts | 4 +- .../typescript/ai-ollama/src/adapters/text.ts | 4 +- .../typescript/ai-openai/src/adapters/text.ts | 4 +- .../ai-openrouter/src/adapters/text.ts | 92 +++++++++---------- .../src/activities/chat/stream/processor.ts | 10 ++ .../ai/src/activities/chat/stream/types.ts | 1 + .../ai/src/activities/generateVideo/index.ts | 2 + .../activities/stream-generation-result.ts | 2 + packages/typescript/ai/tests/chat.test.ts | 4 +- 12 files changed, 75 insertions(+), 60 deletions(-) diff --git a/packages/typescript/ai-anthropic/src/adapters/text.ts b/packages/typescript/ai-anthropic/src/adapters/text.ts index 401ac4b0..c88c1159 100644 --- a/packages/typescript/ai-anthropic/src/adapters/text.ts +++ b/packages/typescript/ai-anthropic/src/adapters/text.ts @@ -544,8 +544,8 @@ export class AnthropicTextAdapter< let currentToolIndex = -1 // AG-UI lifecycle tracking - const runId = genId() - const threadId = options.threadId || genId() + const runId = options.runId ?? genId() + const threadId = options.threadId ?? genId() const messageId = genId() let stepId: string | null = null let reasoningMessageId: string | null = null diff --git a/packages/typescript/ai-gemini/src/adapters/text.ts b/packages/typescript/ai-gemini/src/adapters/text.ts index 28f510b5..29f35f9e 100644 --- a/packages/typescript/ai-gemini/src/adapters/text.ts +++ b/packages/typescript/ai-gemini/src/adapters/text.ts @@ -219,8 +219,8 @@ export class GeminiTextAdapter< let nextToolIndex = 0 // AG-UI lifecycle tracking - const runId = generateId(this.name) - const threadId = options.threadId || generateId(this.name) + const runId = options.runId ?? generateId(this.name) + const threadId = options.threadId ?? generateId(this.name) const messageId = generateId(this.name) let stepId: string | null = null let reasoningMessageId: string | null = null diff --git a/packages/typescript/ai-grok/src/adapters/text.ts b/packages/typescript/ai-grok/src/adapters/text.ts index fc0f3a8f..9902354f 100644 --- a/packages/typescript/ai-grok/src/adapters/text.ts +++ b/packages/typescript/ai-grok/src/adapters/text.ts @@ -78,8 +78,8 @@ export class GrokTextAdapter< // AG-UI lifecycle tracking (mutable state object for ESLint compatibility) const aguiState = { - runId: generateId(this.name), - threadId: options.threadId || generateId(this.name), + runId: options.runId ?? generateId(this.name), + threadId: options.threadId ?? generateId(this.name), messageId: generateId(this.name), timestamp, hasEmittedRunStarted: false, diff --git a/packages/typescript/ai-groq/src/adapters/text.ts b/packages/typescript/ai-groq/src/adapters/text.ts index 835f2811..7a170b80 100644 --- a/packages/typescript/ai-groq/src/adapters/text.ts +++ b/packages/typescript/ai-groq/src/adapters/text.ts @@ -80,8 +80,8 @@ export class GroqTextAdapter< const timestamp = Date.now() const aguiState = { - runId: generateId(this.name), - threadId: options.threadId || generateId(this.name), + runId: options.runId ?? generateId(this.name), + threadId: options.threadId ?? generateId(this.name), messageId: generateId(this.name), timestamp, hasEmittedRunStarted: false, diff --git a/packages/typescript/ai-ollama/src/adapters/text.ts b/packages/typescript/ai-ollama/src/adapters/text.ts index c30afa5c..f707b674 100644 --- a/packages/typescript/ai-ollama/src/adapters/text.ts +++ b/packages/typescript/ai-ollama/src/adapters/text.ts @@ -196,8 +196,8 @@ export class OllamaTextAdapter extends BaseTextAdapter< const toolCallsEmitted = new Set() // AG-UI lifecycle tracking - const runId = generateId('run') - const threadId = options.threadId || generateId('thread') + const runId = options.runId ?? generateId('run') + const threadId = options.threadId ?? generateId('thread') const messageId = generateId('msg') let stepId: string | null = null let reasoningMessageId: string | null = null diff --git a/packages/typescript/ai-openai/src/adapters/text.ts b/packages/typescript/ai-openai/src/adapters/text.ts index 3c7df5c0..028d62c4 100644 --- a/packages/typescript/ai-openai/src/adapters/text.ts +++ b/packages/typescript/ai-openai/src/adapters/text.ts @@ -262,8 +262,8 @@ export class OpenAITextAdapter< let model: string = options.model // AG-UI lifecycle tracking - const runId = genId() - const threadId = options.threadId || genId() + const runId = options.runId ?? genId() + const threadId = options.threadId ?? genId() const messageId = genId() let stepId: string | null = null let reasoningMessageId: string | null = null diff --git a/packages/typescript/ai-openrouter/src/adapters/text.ts b/packages/typescript/ai-openrouter/src/adapters/text.ts index b80482e5..a4193f08 100644 --- a/packages/typescript/ai-openrouter/src/adapters/text.ts +++ b/packages/typescript/ai-openrouter/src/adapters/text.ts @@ -108,8 +108,8 @@ export class OpenRouterTextAdapter< let currentModel = options.model // AG-UI lifecycle tracking const aguiState: AGUIState = { - runId: this.generateId(), - threadId: options.threadId || this.generateId(), + runId: options.runId ?? this.generateId(), + threadId: options.threadId ?? this.generateId(), messageId: this.generateId(), stepId: null, reasoningMessageId: null, @@ -305,50 +305,6 @@ export class OpenRouterTextAdapter< const delta = choice.delta const finishReason = choice.finishReason - if (delta.content) { - // Close reasoning before text starts - if (aguiState.reasoningMessageId && !aguiState.hasClosedReasoning) { - aguiState.hasClosedReasoning = true - yield asChunk({ - type: 'REASONING_MESSAGE_END', - messageId: aguiState.reasoningMessageId, - model: meta.model, - timestamp: meta.timestamp, - }) - yield asChunk({ - type: 'REASONING_END', - messageId: aguiState.reasoningMessageId, - model: meta.model, - timestamp: meta.timestamp, - }) - } - - // Emit TEXT_MESSAGE_START on first text content - if (!aguiState.hasEmittedTextMessageStart) { - aguiState.hasEmittedTextMessageStart = true - yield asChunk({ - type: 'TEXT_MESSAGE_START', - messageId: aguiState.messageId, - model: meta.model, - timestamp: meta.timestamp, - role: 'assistant', - }) - } - - accumulated.content += delta.content - updateAccumulated(accumulated.reasoning, accumulated.content) - - // Emit AG-UI TEXT_MESSAGE_CONTENT - yield asChunk({ - type: 'TEXT_MESSAGE_CONTENT', - messageId: aguiState.messageId, - model: meta.model, - timestamp: meta.timestamp, - delta: delta.content, - content: accumulated.content, - }) - } - if (delta.reasoningDetails) { for (const detail of delta.reasoningDetails) { if (detail.type === 'reasoning.text') { @@ -472,6 +428,50 @@ export class OpenRouterTextAdapter< } } + if (delta.content) { + // Close reasoning before text starts + if (aguiState.reasoningMessageId && !aguiState.hasClosedReasoning) { + aguiState.hasClosedReasoning = true + yield asChunk({ + type: 'REASONING_MESSAGE_END', + messageId: aguiState.reasoningMessageId, + model: meta.model, + timestamp: meta.timestamp, + }) + yield asChunk({ + type: 'REASONING_END', + messageId: aguiState.reasoningMessageId, + model: meta.model, + timestamp: meta.timestamp, + }) + } + + // Emit TEXT_MESSAGE_START on first text content + if (!aguiState.hasEmittedTextMessageStart) { + aguiState.hasEmittedTextMessageStart = true + yield asChunk({ + type: 'TEXT_MESSAGE_START', + messageId: aguiState.messageId, + model: meta.model, + timestamp: meta.timestamp, + role: 'assistant', + }) + } + + accumulated.content += delta.content + updateAccumulated(accumulated.reasoning, accumulated.content) + + // Emit AG-UI TEXT_MESSAGE_CONTENT + yield asChunk({ + type: 'TEXT_MESSAGE_CONTENT', + messageId: aguiState.messageId, + model: meta.model, + timestamp: meta.timestamp, + delta: delta.content, + content: accumulated.content, + }) + } + if (delta.toolCalls) { for (const tc of delta.toolCalls) { const existing = toolCallBuffers.get(tc.index) diff --git a/packages/typescript/ai/src/activities/chat/stream/processor.ts b/packages/typescript/ai/src/activities/chat/stream/processor.ts index cec48e40..40d350e1 100644 --- a/packages/typescript/ai/src/activities/chat/stream/processor.ts +++ b/packages/typescript/ai/src/activities/chat/stream/processor.ts @@ -534,6 +534,7 @@ export class StreamProcessor { currentSegmentText: '', lastEmittedText: '', thinkingContent: '', + hasSeenReasoningEvents: false, toolCalls: new Map(), toolCallOrder: [], hasToolCallsSinceTextStart: false, @@ -1100,6 +1101,14 @@ export class StreamProcessor { this.getActiveAssistantMessageId() ?? undefined, ) + // During the transition period, adapters emit BOTH STEP_FINISHED and + // REASONING_MESSAGE_CONTENT with the same delta. If we've already processed + // REASONING_MESSAGE_CONTENT events for this message, skip the duplicate + // thinking content from STEP_FINISHED to avoid doubled content. + if (state.hasSeenReasoningEvents) { + return + } + const previous = state.thinkingContent let nextThinking = previous @@ -1143,6 +1152,7 @@ export class StreamProcessor { this.getActiveAssistantMessageId() ?? undefined, ) + state.hasSeenReasoningEvents = true state.thinkingContent = state.thinkingContent + chunk.delta this.messages = updateThinkingPart( diff --git a/packages/typescript/ai/src/activities/chat/stream/types.ts b/packages/typescript/ai/src/activities/chat/stream/types.ts index c1806238..b91bb457 100644 --- a/packages/typescript/ai/src/activities/chat/stream/types.ts +++ b/packages/typescript/ai/src/activities/chat/stream/types.ts @@ -57,6 +57,7 @@ export interface MessageStreamState { currentSegmentText: string lastEmittedText: string thinkingContent: string + hasSeenReasoningEvents: boolean toolCalls: Map toolCallOrder: Array hasToolCallsSinceTextStart: boolean diff --git a/packages/typescript/ai/src/activities/generateVideo/index.ts b/packages/typescript/ai/src/activities/generateVideo/index.ts index 375cf92b..b8048fab 100644 --- a/packages/typescript/ai/src/activities/generateVideo/index.ts +++ b/packages/typescript/ai/src/activities/generateVideo/index.ts @@ -347,6 +347,8 @@ async function* runStreamingVideoGeneration< } catch (error: any) { yield { type: 'RUN_ERROR', + runId, + threadId, message: error.message || 'Video generation failed', code: error.code, error: { diff --git a/packages/typescript/ai/src/activities/stream-generation-result.ts b/packages/typescript/ai/src/activities/stream-generation-result.ts index 6352dc63..54f03706 100644 --- a/packages/typescript/ai/src/activities/stream-generation-result.ts +++ b/packages/typescript/ai/src/activities/stream-generation-result.ts @@ -55,6 +55,8 @@ export async function* streamGenerationResult( } catch (error: any) { yield { type: EventType.RUN_ERROR, + runId, + threadId, message: error.message || 'Generation failed', code: error.code, // Deprecated nested form for backward compatibility diff --git a/packages/typescript/ai/tests/chat.test.ts b/packages/typescript/ai/tests/chat.test.ts index b529da71..2d5aabfa 100644 --- a/packages/typescript/ai/tests/chat.test.ts +++ b/packages/typescript/ai/tests/chat.test.ts @@ -1312,11 +1312,11 @@ describe('chat()', () => { const runStarted = chunks.find((c) => c.type === 'RUN_STARTED') expect(runStarted).toBeDefined() - expect((runStarted as any).threadId).toBeDefined() + expect((runStarted as any).threadId).toBe('thread-1') const runFinished = chunks.find((c) => c.type === 'RUN_FINISHED') expect(runFinished).toBeDefined() - expect((runFinished as any).threadId).toBeDefined() + expect((runFinished as any).threadId).toBe('thread-1') }) it('should strip model field from yielded events', async () => { From eeb30609287d0ae6effadd69e3fd83ec2d3f3651 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 1 Apr 2026 13:21:30 +0200 Subject: [PATCH 14/30] fix(smoke-tests): update harness to read spec-compliant event fields TOOL_CALL_START now uses toolCallName (spec) instead of toolName (deprecated). TOOL_CALL_END fields (toolName, input, result) are stripped by spec middleware; harness now falls back to data captured during START/ARGS phases. Added TOOL_CALL_RESULT handler for spec-compliant tool result delivery. RUN_FINISHED finishReason/usage are optional extensions. --- .../smoke-tests/adapters/src/harness.ts | 74 ++++++++++++++----- 1 file changed, 56 insertions(+), 18 deletions(-) diff --git a/packages/typescript/smoke-tests/adapters/src/harness.ts b/packages/typescript/smoke-tests/adapters/src/harness.ts index 65beb0c4..84b68579 100644 --- a/packages/typescript/smoke-tests/adapters/src/harness.ts +++ b/packages/typescript/smoke-tests/adapters/src/harness.ts @@ -199,7 +199,7 @@ export async function captureStream(opts: { // AG-UI TEXT_MESSAGE_CONTENT event if (chunk.type === 'TEXT_MESSAGE_CONTENT') { chunkData.delta = chunk.delta - chunkData.content = chunk.content + chunkData.content = (chunk as any).content // stripped by spec middleware chunkData.role = 'assistant' const delta = chunk.delta || '' fullResponse += delta @@ -207,7 +207,7 @@ export async function captureStream(opts: { if (!assistantDraft) { assistantDraft = { role: 'assistant', - content: chunk.content || '', + content: delta, toolCalls: [], } } else { @@ -217,8 +217,10 @@ export async function captureStream(opts: { // AG-UI TOOL_CALL_START event else if (chunk.type === 'TOOL_CALL_START') { const id = chunk.toolCallId + const name = + (chunk as any).toolCallName || (chunk as any).toolName || '' toolCallsInProgress.set(id, { - name: chunk.toolName, + name, args: '', }) @@ -227,27 +229,36 @@ export async function captureStream(opts: { } chunkData.toolCallId = chunk.toolCallId - chunkData.toolName = chunk.toolName + chunkData.toolName = name } // AG-UI TOOL_CALL_ARGS event else if (chunk.type === 'TOOL_CALL_ARGS') { const id = chunk.toolCallId const existing = toolCallsInProgress.get(id) if (existing) { - existing.args = chunk.args || existing.args + (chunk.delta || '') + // Accumulate from delta (spec field) or use args (deprecated extension) + existing.args = + (chunk as any).args || existing.args + (chunk.delta || '') } chunkData.toolCallId = chunk.toolCallId chunkData.delta = chunk.delta - chunkData.args = chunk.args + chunkData.args = (chunk as any).args } // AG-UI TOOL_CALL_END event else if (chunk.type === 'TOOL_CALL_END') { const id = chunk.toolCallId const inProgress = toolCallsInProgress.get(id) - const name = chunk.toolName || inProgress?.name || '' + // toolName/toolCallName/input/result are stripped by spec middleware; + // fall back to data captured during TOOL_CALL_START/TOOL_CALL_ARGS + const name = + (chunk as any).toolCallName || + (chunk as any).toolName || + inProgress?.name || + '' const args = - inProgress?.args || (chunk.input ? JSON.stringify(chunk.input) : '') + inProgress?.args || + ((chunk as any).input ? JSON.stringify((chunk as any).input) : '') toolCallMap.set(id, { id, @@ -269,20 +280,40 @@ export async function captureStream(opts: { }) chunkData.toolCallId = chunk.toolCallId - chunkData.toolName = chunk.toolName - chunkData.input = chunk.input + chunkData.toolName = name - // AG-UI tool results are included in TOOL_CALL_END events - if (chunk.result !== undefined) { - chunkData.result = chunk.result + // Legacy: AG-UI tool results were included in TOOL_CALL_END events + const result = (chunk as any).result + if (result !== undefined) { + chunkData.result = result + toolResults.push({ + toolCallId: id, + content: result, + }) + reconstructedMessages.push({ + role: 'tool', + toolCallId: id, + content: result, + }) + } + } + // AG-UI TOOL_CALL_RESULT event (spec-compliant tool result delivery) + else if (chunk.type === 'TOOL_CALL_RESULT') { + const id = (chunk as any).toolCallId + const content = (chunk as any).content + chunkData.toolCallId = id + chunkData.result = content + + // Only add if not already captured from TOOL_CALL_END + if (!toolResults.some((r) => r.toolCallId === id)) { toolResults.push({ toolCallId: id, - content: chunk.result, + content, }) reconstructedMessages.push({ role: 'tool', toolCallId: id, - content: chunk.result, + content, }) } } @@ -310,9 +341,16 @@ export async function captureStream(opts: { } // AG-UI RUN_FINISHED event else if (chunk.type === 'RUN_FINISHED') { - chunkData.finishReason = chunk.finishReason - chunkData.usage = chunk.usage - if (chunk.finishReason === 'stop' && assistantDraft) { + // finishReason and usage are TanStack extensions (stripped by spec middleware) + const finishReason = (chunk as any).finishReason + const usage = (chunk as any).usage + chunkData.finishReason = finishReason + chunkData.usage = usage + // If finishReason is available, use it; otherwise assume 'stop' if we have text content + if ( + (finishReason === 'stop' || finishReason === undefined) && + assistantDraft + ) { reconstructedMessages.push(assistantDraft) lastAssistantMessage = assistantDraft assistantDraft = null From 1907e716e7f24425b0df1114e363a5f5326f0941 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:22:46 +0000 Subject: [PATCH 15/30] ci: apply automated fixes --- packages/typescript/smoke-tests/adapters/src/harness.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/typescript/smoke-tests/adapters/src/harness.ts b/packages/typescript/smoke-tests/adapters/src/harness.ts index 84b68579..acf2df26 100644 --- a/packages/typescript/smoke-tests/adapters/src/harness.ts +++ b/packages/typescript/smoke-tests/adapters/src/harness.ts @@ -217,8 +217,7 @@ export async function captureStream(opts: { // AG-UI TOOL_CALL_START event else if (chunk.type === 'TOOL_CALL_START') { const id = chunk.toolCallId - const name = - (chunk as any).toolCallName || (chunk as any).toolName || '' + const name = (chunk as any).toolCallName || (chunk as any).toolName || '' toolCallsInProgress.set(id, { name, args: '', From be289d63311fb23563db95ad842a3c94d93790fd Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 1 Apr 2026 13:26:16 +0200 Subject: [PATCH 16/30] fix(smoke-tests): remove unnecessary as-any casts, use proper type narrowing --- .../smoke-tests/adapters/src/harness.ts | 45 ++++++++----------- 1 file changed, 18 insertions(+), 27 deletions(-) diff --git a/packages/typescript/smoke-tests/adapters/src/harness.ts b/packages/typescript/smoke-tests/adapters/src/harness.ts index acf2df26..59034d8b 100644 --- a/packages/typescript/smoke-tests/adapters/src/harness.ts +++ b/packages/typescript/smoke-tests/adapters/src/harness.ts @@ -192,14 +192,13 @@ export async function captureStream(opts: { index: chunkIndex, type: chunk.type, timestamp: chunk.timestamp, - id: (chunk as any).id, model: chunk.model, } // AG-UI TEXT_MESSAGE_CONTENT event if (chunk.type === 'TEXT_MESSAGE_CONTENT') { chunkData.delta = chunk.delta - chunkData.content = (chunk as any).content // stripped by spec middleware + chunkData.content = chunk.content // TanStack extension, stripped by spec middleware chunkData.role = 'assistant' const delta = chunk.delta || '' fullResponse += delta @@ -217,7 +216,7 @@ export async function captureStream(opts: { // AG-UI TOOL_CALL_START event else if (chunk.type === 'TOOL_CALL_START') { const id = chunk.toolCallId - const name = (chunk as any).toolCallName || (chunk as any).toolName || '' + const name = chunk.toolCallName || chunk.toolName || '' toolCallsInProgress.set(id, { name, args: '', @@ -235,29 +234,24 @@ export async function captureStream(opts: { const id = chunk.toolCallId const existing = toolCallsInProgress.get(id) if (existing) { - // Accumulate from delta (spec field) or use args (deprecated extension) - existing.args = - (chunk as any).args || existing.args + (chunk.delta || '') + // Accumulate from delta (spec field); args is a deprecated extension (stripped) + existing.args = chunk.args || existing.args + (chunk.delta || '') } chunkData.toolCallId = chunk.toolCallId chunkData.delta = chunk.delta - chunkData.args = (chunk as any).args + chunkData.args = chunk.args } // AG-UI TOOL_CALL_END event else if (chunk.type === 'TOOL_CALL_END') { const id = chunk.toolCallId const inProgress = toolCallsInProgress.get(id) - // toolName/toolCallName/input/result are stripped by spec middleware; + // toolName/input/result are TanStack extensions (stripped by spec middleware); // fall back to data captured during TOOL_CALL_START/TOOL_CALL_ARGS - const name = - (chunk as any).toolCallName || - (chunk as any).toolName || - inProgress?.name || - '' + const name = chunk.toolName || inProgress?.name || '' const args = inProgress?.args || - ((chunk as any).input ? JSON.stringify((chunk as any).input) : '') + (chunk.input ? JSON.stringify(chunk.input) : '') toolCallMap.set(id, { id, @@ -282,24 +276,23 @@ export async function captureStream(opts: { chunkData.toolName = name // Legacy: AG-UI tool results were included in TOOL_CALL_END events - const result = (chunk as any).result - if (result !== undefined) { - chunkData.result = result + if (chunk.result !== undefined) { + chunkData.result = chunk.result toolResults.push({ toolCallId: id, - content: result, + content: chunk.result, }) reconstructedMessages.push({ role: 'tool', toolCallId: id, - content: result, + content: chunk.result, }) } } // AG-UI TOOL_CALL_RESULT event (spec-compliant tool result delivery) else if (chunk.type === 'TOOL_CALL_RESULT') { - const id = (chunk as any).toolCallId - const content = (chunk as any).content + const id = chunk.toolCallId + const content = chunk.content chunkData.toolCallId = id chunkData.result = content @@ -340,14 +333,12 @@ export async function captureStream(opts: { } // AG-UI RUN_FINISHED event else if (chunk.type === 'RUN_FINISHED') { - // finishReason and usage are TanStack extensions (stripped by spec middleware) - const finishReason = (chunk as any).finishReason - const usage = (chunk as any).usage - chunkData.finishReason = finishReason - chunkData.usage = usage + // finishReason and usage are TanStack extensions (stripped by spec middleware at runtime) + chunkData.finishReason = chunk.finishReason + chunkData.usage = chunk.usage // If finishReason is available, use it; otherwise assume 'stop' if we have text content if ( - (finishReason === 'stop' || finishReason === undefined) && + (chunk.finishReason === 'stop' || chunk.finishReason === undefined) && assistantDraft ) { reconstructedMessages.push(assistantDraft) From 6f834da4ee9f86f6efd134610e5e23dafd08014d Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:28:08 +0000 Subject: [PATCH 17/30] ci: apply automated fixes --- packages/typescript/smoke-tests/adapters/src/harness.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/typescript/smoke-tests/adapters/src/harness.ts b/packages/typescript/smoke-tests/adapters/src/harness.ts index 59034d8b..33ed30b3 100644 --- a/packages/typescript/smoke-tests/adapters/src/harness.ts +++ b/packages/typescript/smoke-tests/adapters/src/harness.ts @@ -250,8 +250,7 @@ export async function captureStream(opts: { // fall back to data captured during TOOL_CALL_START/TOOL_CALL_ARGS const name = chunk.toolName || inProgress?.name || '' const args = - inProgress?.args || - (chunk.input ? JSON.stringify(chunk.input) : '') + inProgress?.args || (chunk.input ? JSON.stringify(chunk.input) : '') toolCallMap.set(id, { id, From 2c54b5f25ee87b9e58c2afec004e159d1c166188 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 1 Apr 2026 14:08:06 +0200 Subject: [PATCH 18/30] fix(ai-ollama): emit TOOL_CALL_ARGS before TOOL_CALL_END for spec compliance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ollama doesn't stream tool args incrementally — it delivers them all at once in TOOL_CALL_END.input. Since the strip middleware removes input from TOOL_CALL_END, consumers had no way to get the args. Now emits a TOOL_CALL_ARGS event with the full args as delta before TOOL_CALL_END. --- packages/typescript/ai-ollama/src/adapters/text.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/typescript/ai-ollama/src/adapters/text.ts b/packages/typescript/ai-ollama/src/adapters/text.ts index f707b674..dc1a800b 100644 --- a/packages/typescript/ai-ollama/src/adapters/text.ts +++ b/packages/typescript/ai-ollama/src/adapters/text.ts @@ -256,18 +256,17 @@ export class OllamaTextAdapter extends BaseTextAdapter< parsedInput = actualToolCall.function.arguments } - // Emit TOOL_CALL_ARGS so the stream processor accumulates the - // arguments string (matches OpenAI/Anthropic adapter behavior) - if (argsStr) { - events.push({ + // Emit TOOL_CALL_ARGS with full args (Ollama doesn't stream args incrementally) + events.push( + asChunk({ type: 'TOOL_CALL_ARGS', toolCallId, model: chunk.model, timestamp, delta: argsStr, args: argsStr, - }) - } + }), + ) // Emit TOOL_CALL_END events.push( From 2f92e8bdbe0dcb147ba96ccb7b75855def6bc7e6 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 1 Apr 2026 16:07:44 +0200 Subject: [PATCH 19/30] fix(ai): hide strip-to-spec middleware from devtools instrumentation --- .../typescript/ai/src/activities/chat/middleware/compose.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/typescript/ai/src/activities/chat/middleware/compose.ts b/packages/typescript/ai/src/activities/chat/middleware/compose.ts index cfa42c6e..e29f0f12 100644 --- a/packages/typescript/ai/src/activities/chat/middleware/compose.ts +++ b/packages/typescript/ai/src/activities/chat/middleware/compose.ts @@ -17,7 +17,7 @@ import type { /** Check if a middleware should be skipped for instrumentation events. */ function shouldSkipInstrumentation(mw: ChatMiddleware): boolean { - return mw.name === 'devtools' + return mw.name === 'devtools' || mw.name === 'strip-to-spec' } /** Build the base context for middleware instrumentation events. */ From 7904137798a64f4e13cd2d834f36ccf2df5e599a Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 1 Apr 2026 16:41:41 +0200 Subject: [PATCH 20/30] fix(ai): handle TOOL_CALL_RESULT in StreamProcessor to create tool-result parts Root cause: The strip middleware removes 'result' from TOOL_CALL_END events. The StreamProcessor's TOOL_CALL_END handler only creates tool-result parts when chunk.result is present. With it stripped, no tool-result parts were created on the client side. TOOL_CALL_RESULT events (spec-compliant tool result delivery) were received but ignored (no-op). Without tool-result parts, areAllToolsComplete() behaved incorrectly, and the client could not detect server tool completion. Fix: Handle TOOL_CALL_RESULT by creating tool-result parts and updating tool-call output, mirroring TOOL_CALL_END's result handling logic. --- .../src/activities/chat/stream/processor.ts | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/typescript/ai/src/activities/chat/stream/processor.ts b/packages/typescript/ai/src/activities/chat/stream/processor.ts index 40d350e1..dee7ba76 100644 --- a/packages/typescript/ai/src/activities/chat/stream/processor.ts +++ b/packages/typescript/ai/src/activities/chat/stream/processor.ts @@ -507,7 +507,7 @@ export class StreamProcessor { break case 'TOOL_CALL_RESULT': - // Tool result handled by chat activity + this.handleToolCallResultEvent(chunk) break default: @@ -1031,6 +1031,44 @@ export class StreamProcessor { } } + /** + * Handle TOOL_CALL_RESULT event (AG-UI spec). + * + * Creates a tool-result part and updates the tool-call output field, + * mirroring the logic from TOOL_CALL_END when it carries a result. + * This is the spec-compliant path for delivering tool results to the client. + */ + private handleToolCallResultEvent( + chunk: Extract, + ): void { + const messageId = this.toolCallToMessage.get(chunk.toolCallId) + if (!messageId) return + + // Step 1: Update the tool-call part's output field + let output: unknown + try { + output = JSON.parse(chunk.content) + } catch { + output = chunk.content + } + this.messages = updateToolCallWithOutput( + this.messages, + chunk.toolCallId, + output, + ) + + // Step 2: Create/update the tool-result part + const resultState: ToolResultState = 'complete' + this.messages = updateToolResultPart( + this.messages, + messageId, + chunk.toolCallId, + chunk.content, + resultState, + ) + this.emitMessagesChange() + } + /** * Handle RUN_STARTED event. * From 5d1304406df4098d7f666608c136d0d6f5b587db Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 1 Apr 2026 17:47:27 +0200 Subject: [PATCH 21/30] fix(ai): stop stripping finishReason from RUN_FINISHED events finishReason is essential for client-side continuation logic. Without it, the chat-client cannot distinguish 'stop' (no continuation needed) from 'tool_calls' (client tools need execution), causing infinite request loops when server-side tool results leave tool-call parts as the last message part. --- packages/typescript/ai/src/strip-to-spec-middleware.ts | 2 +- packages/typescript/ai/tests/chat.test.ts | 7 ++++--- .../typescript/ai/tests/strip-to-spec-middleware.test.ts | 8 +++++--- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/typescript/ai/src/strip-to-spec-middleware.ts b/packages/typescript/ai/src/strip-to-spec-middleware.ts index 026e63e9..ed2dc97d 100644 --- a/packages/typescript/ai/src/strip-to-spec-middleware.ts +++ b/packages/typescript/ai/src/strip-to-spec-middleware.ts @@ -15,7 +15,7 @@ const STRIP_BY_TYPE: Record> = { TOOL_CALL_START: new Set(['toolName', 'index', 'providerMetadata']), TOOL_CALL_ARGS: new Set(['args']), TOOL_CALL_END: new Set(['toolName', 'toolCallName', 'input', 'result']), - RUN_FINISHED: new Set(['finishReason', 'usage']), + RUN_FINISHED: new Set(['usage']), RUN_ERROR: new Set(['error']), STEP_STARTED: new Set(['stepId', 'stepType']), STEP_FINISHED: new Set(['stepId', 'delta', 'content']), diff --git a/packages/typescript/ai/tests/chat.test.ts b/packages/typescript/ai/tests/chat.test.ts index 2d5aabfa..a9a6f32a 100644 --- a/packages/typescript/ai/tests/chat.test.ts +++ b/packages/typescript/ai/tests/chat.test.ts @@ -1386,7 +1386,7 @@ describe('chat()', () => { } }) - it('should strip finishReason and usage from RUN_FINISHED events', async () => { + it('should strip usage but keep finishReason on RUN_FINISHED events', async () => { const { adapter } = createMockAdapter({ iterations: [ [ @@ -1408,9 +1408,10 @@ describe('chat()', () => { const runFinished = chunks.find((c) => c.type === 'RUN_FINISHED') expect(runFinished).toBeDefined() - // These internal extension fields should be stripped - expect('finishReason' in runFinished!).toBe(false) + // usage is stripped (internal extension) expect('usage' in runFinished!).toBe(false) + // finishReason is kept — needed by client to detect tool_calls vs stop + expect((runFinished as any).finishReason).toBe('stop') }) it('should emit TOOL_CALL_RESULT events during agent loop', async () => { diff --git a/packages/typescript/ai/tests/strip-to-spec-middleware.test.ts b/packages/typescript/ai/tests/strip-to-spec-middleware.test.ts index 8a5f8fb2..d7b83814 100644 --- a/packages/typescript/ai/tests/strip-to-spec-middleware.test.ts +++ b/packages/typescript/ai/tests/strip-to-spec-middleware.test.ts @@ -117,7 +117,7 @@ describe('stripToSpec', () => { expect(result).toHaveProperty('timestamp') }) - it('strips RUN_FINISHED to spec fields (removes finishReason, usage)', () => { + it('strips RUN_FINISHED usage but keeps finishReason (needed by client)', () => { const chunk = makeChunk('RUN_FINISHED', { runId: 'run-1', model: 'gpt-4o', @@ -130,8 +130,9 @@ describe('stripToSpec', () => { }) const result = stripToSpec(chunk) as Record expect(result).not.toHaveProperty('model') - expect(result).not.toHaveProperty('finishReason') expect(result).not.toHaveProperty('usage') + // finishReason is kept — client needs it to detect tool_calls vs stop + expect(result).toHaveProperty('finishReason', 'stop') expect(result).toHaveProperty('type', 'RUN_FINISHED') expect(result).toHaveProperty('runId', 'run-1') expect(result).toHaveProperty('timestamp') @@ -237,7 +238,8 @@ describe('stripToSpec', () => { const result = stripToSpec(chunk) as Record expect(result).not.toHaveProperty('rawEvent') expect(result).not.toHaveProperty('model') - expect(result).not.toHaveProperty('finishReason') + // finishReason is kept (needed by client) + expect(result).toHaveProperty('finishReason', 'stop') expect(result).toHaveProperty('type', 'RUN_FINISHED') expect(result).toHaveProperty('runId', 'run-1') }) From 374b82a9eafee6937cf33fd06a986813614ddb09 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 1 Apr 2026 17:53:07 +0200 Subject: [PATCH 22/30] fix(ai): only strip deprecated aliases and rawEvent, keep all extras @ag-ui/core BaseEventSchema uses .passthrough(), so extra fields are allowed and won't break spec validation. Only strip: - Deprecated aliases: toolName, stepId, state (nudge toward spec names) - Deprecated nested error object on RUN_ERROR - rawEvent (debug payload, potentially large) Keep everything else: model, content, args, usage, finishReason, input, result, index, providerMetadata, stepType, delta, etc. --- .../ai/src/strip-to-spec-middleware.ts | 37 ++- packages/typescript/ai/tests/chat.test.ts | 37 +-- .../ai/tests/strip-to-spec-middleware.test.ts | 244 ++++++++---------- 3 files changed, 138 insertions(+), 180 deletions(-) diff --git a/packages/typescript/ai/src/strip-to-spec-middleware.ts b/packages/typescript/ai/src/strip-to-spec-middleware.ts index ed2dc97d..b50b7337 100644 --- a/packages/typescript/ai/src/strip-to-spec-middleware.ts +++ b/packages/typescript/ai/src/strip-to-spec-middleware.ts @@ -2,31 +2,42 @@ import type { ChatMiddleware } from './activities/chat/middleware/types' import type { StreamChunk } from './types' /** - * Set of fields to always strip from events (TanStack extensions not in @ag-ui/core spec). + * Fields to always strip from events. + * + * - `rawEvent`: Debug-only provider payload, potentially large. Not for wire. */ -const ALWAYS_STRIP = new Set(['model', 'rawEvent']) +const ALWAYS_STRIP = new Set(['rawEvent']) /** - * Per-event-type fields to strip. These are TanStack-internal extension fields - * and deprecated aliases that should not appear on the wire. + * Per-event-type fields to strip. Only deprecated aliases and fields that + * conflict with the spec are removed. Extra fields are allowed by @ag-ui/core's + * BaseEventSchema (.passthrough()), so we keep useful extensions like `model`, + * `content`, `usage`, `finishReason`, `input`, `result`, etc. */ const STRIP_BY_TYPE: Record> = { - TEXT_MESSAGE_CONTENT: new Set(['content']), - TOOL_CALL_START: new Set(['toolName', 'index', 'providerMetadata']), - TOOL_CALL_ARGS: new Set(['args']), - TOOL_CALL_END: new Set(['toolName', 'toolCallName', 'input', 'result']), - RUN_FINISHED: new Set(['usage']), + TOOL_CALL_START: new Set(['toolName']), + TOOL_CALL_END: new Set(['toolName']), RUN_ERROR: new Set(['error']), - STEP_STARTED: new Set(['stepId', 'stepType']), - STEP_FINISHED: new Set(['stepId', 'delta', 'content']), + STEP_STARTED: new Set(['stepId']), + STEP_FINISHED: new Set(['stepId']), STATE_SNAPSHOT: new Set(['state']), } /** - * Strip non-spec fields from a StreamChunk, producing an @ag-ui/core spec-compliant event. + * Strip deprecated aliases and debug fields from a StreamChunk. + * + * @ag-ui/core's BaseEventSchema uses `.passthrough()`, so extra fields + * (model, content, usage, finishReason, etc.) are allowed and won't break + * spec validation. We only strip: + * - Deprecated field aliases (toolName, stepId, state) to nudge consumers + * toward spec names (toolCallName, stepName, snapshot) + * - The deprecated nested `error` object on RUN_ERROR (spec uses flat message/code) + * - `rawEvent` (debug payload, potentially large) */ export function stripToSpec(chunk: StreamChunk): StreamChunk { const typeStrip = STRIP_BY_TYPE[chunk.type] + if (!typeStrip && ALWAYS_STRIP.size === 0) return chunk + const result: Record = {} for (const [key, value] of Object.entries(chunk)) { @@ -39,7 +50,7 @@ export function stripToSpec(chunk: StreamChunk): StreamChunk { } /** - * Middleware that strips non-spec fields from events. + * Middleware that strips deprecated aliases and debug fields from events. * Should always be the LAST middleware in the chain. */ export function stripToSpecMiddleware(): ChatMiddleware { diff --git a/packages/typescript/ai/tests/chat.test.ts b/packages/typescript/ai/tests/chat.test.ts index a9a6f32a..5ae43465 100644 --- a/packages/typescript/ai/tests/chat.test.ts +++ b/packages/typescript/ai/tests/chat.test.ts @@ -1319,33 +1319,7 @@ describe('chat()', () => { expect((runFinished as any).threadId).toBe('thread-1') }) - it('should strip model field from yielded events', async () => { - const { adapter } = createMockAdapter({ - iterations: [ - [ - ev.runStarted(), - ev.textStart(), - ev.textContent('Hi'), - ev.textEnd(), - ev.runFinished('stop'), - ], - ], - }) - - const stream = chat({ - adapter, - messages: [{ role: 'user', content: 'Hello' }], - }) - - const chunks = await collectChunks(stream as AsyncIterable) - - // No yielded event should have the model field (it's stripped) - for (const chunk of chunks) { - expect('model' in chunk).toBe(false) - } - }) - - it('should strip toolName from TOOL_CALL_START events (only toolCallName)', async () => { + it('should strip deprecated toolName but keep extras on yielded events', async () => { const { adapter } = createMockAdapter({ iterations: [ [ @@ -1386,7 +1360,7 @@ describe('chat()', () => { } }) - it('should strip usage but keep finishReason on RUN_FINISHED events', async () => { + it('should keep finishReason on RUN_FINISHED events', async () => { const { adapter } = createMockAdapter({ iterations: [ [ @@ -1408,9 +1382,6 @@ describe('chat()', () => { const runFinished = chunks.find((c) => c.type === 'RUN_FINISHED') expect(runFinished).toBeDefined() - // usage is stripped (internal extension) - expect('usage' in runFinished!).toBe(false) - // finishReason is kept — needed by client to detect tool_calls vs stop expect((runFinished as any).finishReason).toBe('stop') }) @@ -1447,8 +1418,8 @@ describe('chat()', () => { expect(resultChunks.length).toBeGreaterThanOrEqual(1) expect((resultChunks[0] as any).toolCallId).toBe('tc-1') expect((resultChunks[0] as any).content).toContain('72') - // model should be stripped - expect('model' in resultChunks[0]!).toBe(false) + // model is kept (passthrough allows extra fields) + expect((resultChunks[0] as any).toolCallId).toBeDefined() }) }) }) diff --git a/packages/typescript/ai/tests/strip-to-spec-middleware.test.ts b/packages/typescript/ai/tests/strip-to-spec-middleware.test.ts index d7b83814..e4439e19 100644 --- a/packages/typescript/ai/tests/strip-to-spec-middleware.test.ts +++ b/packages/typescript/ai/tests/strip-to-spec-middleware.test.ts @@ -5,23 +5,19 @@ import type { StreamChunk } from '../src/types' /** * Helper to create a StreamChunk with the given type and fields. */ -function makeChunk(type: string, fields: Record): StreamChunk { +function makeChunk( + type: string, + fields: Record, +): StreamChunk { return { type, timestamp: Date.now(), ...fields } as unknown as StreamChunk } describe('stripToSpec', () => { - it('removes `model` from all event types', () => { - const chunk = makeChunk('RUN_STARTED', { - runId: 'run-1', - model: 'gpt-4o', - }) - const result = stripToSpec(chunk) as Record - expect(result).not.toHaveProperty('model') - expect(result).toHaveProperty('runId', 'run-1') - expect(result).toHaveProperty('type', 'RUN_STARTED') - }) + // ========================================================================= + // Always stripped: rawEvent (debug payload, potentially large) + // ========================================================================= - it('removes `rawEvent` from all event types', () => { + it('strips rawEvent from all events', () => { const chunk = makeChunk('TEXT_MESSAGE_START', { messageId: 'msg-1', role: 'assistant', @@ -30,26 +26,16 @@ describe('stripToSpec', () => { }) const result = stripToSpec(chunk) as Record expect(result).not.toHaveProperty('rawEvent') - expect(result).not.toHaveProperty('model') + // model is kept (passthrough) + expect(result).toHaveProperty('model', 'gpt-4o') expect(result).toHaveProperty('messageId', 'msg-1') }) - it('removes accumulated `content` from TEXT_MESSAGE_CONTENT, keeps `delta`', () => { - const chunk = makeChunk('TEXT_MESSAGE_CONTENT', { - messageId: 'msg-1', - delta: 'Hello', - content: 'Hello World', - model: 'gpt-4o', - }) - const result = stripToSpec(chunk) as Record - expect(result).not.toHaveProperty('content') - expect(result).not.toHaveProperty('model') - expect(result).toHaveProperty('delta', 'Hello') - expect(result).toHaveProperty('messageId', 'msg-1') - expect(result).toHaveProperty('type', 'TEXT_MESSAGE_CONTENT') - }) + // ========================================================================= + // Deprecated aliases stripped: toolName, stepId, state, error (nested) + // ========================================================================= - it('removes `toolName`, `index`, `providerMetadata` from TOOL_CALL_START, keeps `toolCallName`', () => { + it('strips deprecated toolName from TOOL_CALL_START, keeps toolCallName', () => { const chunk = makeChunk('TOOL_CALL_START', { toolCallId: 'tc-1', toolCallName: 'getTodos', @@ -60,30 +46,14 @@ describe('stripToSpec', () => { }) const result = stripToSpec(chunk) as Record expect(result).not.toHaveProperty('toolName') - expect(result).not.toHaveProperty('index') - expect(result).not.toHaveProperty('providerMetadata') - expect(result).not.toHaveProperty('model') - expect(result).toHaveProperty('toolCallId', 'tc-1') + // These extras are kept (passthrough) expect(result).toHaveProperty('toolCallName', 'getTodos') - expect(result).toHaveProperty('type', 'TOOL_CALL_START') - }) - - it('removes `args` from TOOL_CALL_ARGS, keeps `delta`', () => { - const chunk = makeChunk('TOOL_CALL_ARGS', { - toolCallId: 'tc-1', - delta: '{"userId":', - args: '{"userId":', - model: 'gpt-4o', - }) - const result = stripToSpec(chunk) as Record - expect(result).not.toHaveProperty('args') - expect(result).not.toHaveProperty('model') - expect(result).toHaveProperty('delta', '{"userId":') - expect(result).toHaveProperty('toolCallId', 'tc-1') - expect(result).toHaveProperty('type', 'TOOL_CALL_ARGS') + expect(result).toHaveProperty('index', 0) + expect(result).toHaveProperty('providerMetadata') + expect(result).toHaveProperty('model', 'gpt-4o') }) - it('strips TOOL_CALL_END to only `toolCallId` + base fields', () => { + it('strips deprecated toolName from TOOL_CALL_END, keeps extras', () => { const chunk = makeChunk('TOOL_CALL_END', { toolCallId: 'tc-1', toolName: 'getTodos', @@ -94,53 +64,47 @@ describe('stripToSpec', () => { }) const result = stripToSpec(chunk) as Record expect(result).not.toHaveProperty('toolName') - expect(result).not.toHaveProperty('toolCallName') - expect(result).not.toHaveProperty('input') - expect(result).not.toHaveProperty('result') - expect(result).not.toHaveProperty('model') - expect(result).toHaveProperty('toolCallId', 'tc-1') - expect(result).toHaveProperty('type', 'TOOL_CALL_END') - expect(result).toHaveProperty('timestamp') + // These extras are kept (passthrough) + expect(result).toHaveProperty('toolCallName', 'getTodos') + expect(result).toHaveProperty('input') + expect(result).toHaveProperty('result') + expect(result).toHaveProperty('model', 'gpt-4o') }) - it('strips RUN_STARTED to spec fields (threadId, runId, type, timestamp)', () => { - const chunk = makeChunk('RUN_STARTED', { - runId: 'run-1', - threadId: 'thread-1', + it('strips deprecated stepId from STEP_STARTED, keeps stepName and extras', () => { + const chunk = makeChunk('STEP_STARTED', { + stepName: 'thinking', + stepId: 'step-1', + stepType: 'thinking', model: 'gpt-4o', }) const result = stripToSpec(chunk) as Record - expect(result).not.toHaveProperty('model') - expect(result).toHaveProperty('type', 'RUN_STARTED') - expect(result).toHaveProperty('runId', 'run-1') - expect(result).toHaveProperty('threadId', 'thread-1') - expect(result).toHaveProperty('timestamp') + expect(result).not.toHaveProperty('stepId') + // These extras are kept (passthrough) + expect(result).toHaveProperty('stepName', 'thinking') + expect(result).toHaveProperty('stepType', 'thinking') + expect(result).toHaveProperty('model', 'gpt-4o') }) - it('strips RUN_FINISHED usage but keeps finishReason (needed by client)', () => { - const chunk = makeChunk('RUN_FINISHED', { - runId: 'run-1', + it('strips deprecated stepId from STEP_FINISHED, keeps extras', () => { + const chunk = makeChunk('STEP_FINISHED', { + stepName: 'thinking', + stepId: 'step-1', + delta: 'some thinking', + content: 'accumulated thinking', model: 'gpt-4o', - finishReason: 'stop', - usage: { - promptTokens: 100, - completionTokens: 50, - totalTokens: 150, - }, }) const result = stripToSpec(chunk) as Record - expect(result).not.toHaveProperty('model') - expect(result).not.toHaveProperty('usage') - // finishReason is kept — client needs it to detect tool_calls vs stop - expect(result).toHaveProperty('finishReason', 'stop') - expect(result).toHaveProperty('type', 'RUN_FINISHED') - expect(result).toHaveProperty('runId', 'run-1') - expect(result).toHaveProperty('timestamp') + expect(result).not.toHaveProperty('stepId') + // These extras are kept (passthrough) + expect(result).toHaveProperty('stepName', 'thinking') + expect(result).toHaveProperty('delta', 'some thinking') + expect(result).toHaveProperty('content', 'accumulated thinking') + expect(result).toHaveProperty('model', 'gpt-4o') }) - it('strips RUN_ERROR deprecated `error` object, keeps flat `message`/`code`', () => { + it('strips deprecated nested error from RUN_ERROR, keeps flat message/code', () => { const chunk = makeChunk('RUN_ERROR', { - runId: 'run-1', message: 'Something went wrong', code: 'INTERNAL_ERROR', error: { message: 'Something went wrong' }, @@ -148,72 +112,89 @@ describe('stripToSpec', () => { }) const result = stripToSpec(chunk) as Record expect(result).not.toHaveProperty('error') - expect(result).not.toHaveProperty('model') expect(result).toHaveProperty('message', 'Something went wrong') expect(result).toHaveProperty('code', 'INTERNAL_ERROR') - expect(result).toHaveProperty('type', 'RUN_ERROR') + expect(result).toHaveProperty('model', 'gpt-4o') }) - it('strips STEP_STARTED to spec fields (removes stepId, stepType, keeps stepName)', () => { - const chunk = makeChunk('STEP_STARTED', { - stepName: 'thinking', - stepId: 'step-1', - stepType: 'thinking', + it('strips deprecated state from STATE_SNAPSHOT, keeps snapshot', () => { + const chunk = makeChunk('STATE_SNAPSHOT', { + snapshot: { count: 42 }, + state: { count: 42 }, model: 'gpt-4o', }) const result = stripToSpec(chunk) as Record - expect(result).not.toHaveProperty('stepId') - expect(result).not.toHaveProperty('stepType') - expect(result).not.toHaveProperty('model') - expect(result).toHaveProperty('stepName', 'thinking') - expect(result).toHaveProperty('type', 'STEP_STARTED') + expect(result).not.toHaveProperty('state') + expect(result).toHaveProperty('snapshot') + expect(result).toHaveProperty('model', 'gpt-4o') }) - it('strips STEP_FINISHED to spec fields (removes stepId, delta, content, keeps stepName)', () => { - const chunk = makeChunk('STEP_FINISHED', { - stepName: 'thinking', - stepId: 'step-1', - delta: 'some thinking', - content: 'accumulated thinking', + // ========================================================================= + // Extras preserved (passthrough allows them) + // ========================================================================= + + it('keeps model, content on TEXT_MESSAGE_CONTENT', () => { + const chunk = makeChunk('TEXT_MESSAGE_CONTENT', { + messageId: 'msg-1', + delta: 'Hello', + content: 'Hello World', model: 'gpt-4o', }) const result = stripToSpec(chunk) as Record - expect(result).not.toHaveProperty('stepId') - expect(result).not.toHaveProperty('delta') - expect(result).not.toHaveProperty('content') - expect(result).not.toHaveProperty('model') - expect(result).toHaveProperty('stepName', 'thinking') - expect(result).toHaveProperty('type', 'STEP_FINISHED') + expect(result).toHaveProperty('delta', 'Hello') + expect(result).toHaveProperty('content', 'Hello World') + expect(result).toHaveProperty('model', 'gpt-4o') }) - it('strips STATE_SNAPSHOT deprecated `state`, keeps `snapshot`', () => { - const chunk = makeChunk('STATE_SNAPSHOT', { - snapshot: { count: 42 }, - state: { count: 42 }, + it('keeps args on TOOL_CALL_ARGS', () => { + const chunk = makeChunk('TOOL_CALL_ARGS', { + toolCallId: 'tc-1', + delta: '{"userId":', + args: '{"userId":', model: 'gpt-4o', }) const result = stripToSpec(chunk) as Record - expect(result).not.toHaveProperty('state') - expect(result).not.toHaveProperty('model') - expect(result).toHaveProperty('snapshot') - expect((result.snapshot as Record).count).toBe(42) - expect(result).toHaveProperty('type', 'STATE_SNAPSHOT') + expect(result).toHaveProperty('args', '{"userId":') + expect(result).toHaveProperty('delta', '{"userId":') + expect(result).toHaveProperty('model', 'gpt-4o') }) - it('passes through REASONING events (only strips model)', () => { + it('keeps finishReason and usage on RUN_FINISHED', () => { + const chunk = makeChunk('RUN_FINISHED', { + runId: 'run-1', + model: 'gpt-4o', + finishReason: 'stop', + usage: { promptTokens: 100, completionTokens: 50, totalTokens: 150 }, + }) + const result = stripToSpec(chunk) as Record + expect(result).toHaveProperty('finishReason', 'stop') + expect(result).toHaveProperty('usage') + expect(result).toHaveProperty('model', 'gpt-4o') + }) + + it('keeps model on RUN_STARTED', () => { + const chunk = makeChunk('RUN_STARTED', { + runId: 'run-1', + threadId: 'thread-1', + model: 'gpt-4o', + }) + const result = stripToSpec(chunk) as Record + expect(result).toHaveProperty('model', 'gpt-4o') + expect(result).toHaveProperty('threadId', 'thread-1') + }) + + it('passes through REASONING events unchanged (except rawEvent)', () => { const chunk = makeChunk('REASONING_MESSAGE_CONTENT', { messageId: 'msg-1', delta: 'Let me think...', model: 'gpt-4o', }) const result = stripToSpec(chunk) as Record - expect(result).not.toHaveProperty('model') expect(result).toHaveProperty('delta', 'Let me think...') - expect(result).toHaveProperty('messageId', 'msg-1') - expect(result).toHaveProperty('type', 'REASONING_MESSAGE_CONTENT') + expect(result).toHaveProperty('model', 'gpt-4o') }) - it('passes through TOOL_CALL_RESULT (only strips model)', () => { + it('passes through TOOL_CALL_RESULT unchanged (except rawEvent)', () => { const chunk = makeChunk('TOOL_CALL_RESULT', { toolCallId: 'tc-1', messageId: 'msg-result-1', @@ -222,25 +203,20 @@ describe('stripToSpec', () => { model: 'gpt-4o', }) const result = stripToSpec(chunk) as Record - expect(result).not.toHaveProperty('model') - expect(result).toHaveProperty('toolCallId', 'tc-1') + expect(result).toHaveProperty('model', 'gpt-4o') expect(result).toHaveProperty('content', '{"items":[]}') - expect(result).toHaveProperty('type', 'TOOL_CALL_RESULT') }) - it('strips `rawEvent` from events along with other type-specific fields', () => { - const chunk = makeChunk('RUN_FINISHED', { - runId: 'run-1', + it('strips rawEvent even when combined with type-specific strips', () => { + const chunk = makeChunk('TOOL_CALL_START', { + toolCallId: 'tc-1', + toolCallName: 'foo', + toolName: 'foo', rawEvent: { originalPayload: true }, - model: 'gpt-4o', - finishReason: 'stop', }) const result = stripToSpec(chunk) as Record expect(result).not.toHaveProperty('rawEvent') - expect(result).not.toHaveProperty('model') - // finishReason is kept (needed by client) - expect(result).toHaveProperty('finishReason', 'stop') - expect(result).toHaveProperty('type', 'RUN_FINISHED') - expect(result).toHaveProperty('runId', 'run-1') + expect(result).not.toHaveProperty('toolName') + expect(result).toHaveProperty('toolCallName', 'foo') }) }) From 904648033dcb15f8d4f8d9206b688b0deb80ce2b Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 15:57:31 +0000 Subject: [PATCH 23/30] ci: apply automated fixes --- .../typescript/ai/tests/strip-to-spec-middleware.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/typescript/ai/tests/strip-to-spec-middleware.test.ts b/packages/typescript/ai/tests/strip-to-spec-middleware.test.ts index e4439e19..672bfb47 100644 --- a/packages/typescript/ai/tests/strip-to-spec-middleware.test.ts +++ b/packages/typescript/ai/tests/strip-to-spec-middleware.test.ts @@ -5,10 +5,7 @@ import type { StreamChunk } from '../src/types' /** * Helper to create a StreamChunk with the given type and fields. */ -function makeChunk( - type: string, - fields: Record, -): StreamChunk { +function makeChunk(type: string, fields: Record): StreamChunk { return { type, timestamp: Date.now(), ...fields } as unknown as StreamChunk } From af99dde405306b7761a9ab37f28cd386ab3535d6 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 1 Apr 2026 18:10:26 +0200 Subject: [PATCH 24/30] =?UTF-8?q?fix(ai):=20stop=20stripping=20fields=20?= =?UTF-8?q?=E2=80=94=20passthrough=20allows=20all=20extras?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @ag-ui/core BaseEventSchema uses .passthrough() so extra fields are allowed. Only strip the deprecated nested error object from RUN_ERROR (conflicts with spec's flat message/code). Everything else passes through: model, content, toolName, stepId, usage, finishReason, result, input, args, etc. --- .../ai/src/strip-to-spec-middleware.ts | 57 ++--- packages/typescript/ai/tests/chat.test.ts | 7 +- .../ai/tests/strip-to-spec-middleware.test.ts | 199 +++--------------- 3 files changed, 42 insertions(+), 221 deletions(-) diff --git a/packages/typescript/ai/src/strip-to-spec-middleware.ts b/packages/typescript/ai/src/strip-to-spec-middleware.ts index b50b7337..c4648702 100644 --- a/packages/typescript/ai/src/strip-to-spec-middleware.ts +++ b/packages/typescript/ai/src/strip-to-spec-middleware.ts @@ -2,56 +2,27 @@ import type { ChatMiddleware } from './activities/chat/middleware/types' import type { StreamChunk } from './types' /** - * Fields to always strip from events. + * Strip only the deprecated nested `error` object from RUN_ERROR events. + * The flat `message`/`code` fields are the spec-compliant form. * - * - `rawEvent`: Debug-only provider payload, potentially large. Not for wire. - */ -const ALWAYS_STRIP = new Set(['rawEvent']) - -/** - * Per-event-type fields to strip. Only deprecated aliases and fields that - * conflict with the spec are removed. Extra fields are allowed by @ag-ui/core's - * BaseEventSchema (.passthrough()), so we keep useful extensions like `model`, - * `content`, `usage`, `finishReason`, `input`, `result`, etc. - */ -const STRIP_BY_TYPE: Record> = { - TOOL_CALL_START: new Set(['toolName']), - TOOL_CALL_END: new Set(['toolName']), - RUN_ERROR: new Set(['error']), - STEP_STARTED: new Set(['stepId']), - STEP_FINISHED: new Set(['stepId']), - STATE_SNAPSHOT: new Set(['state']), -} - -/** - * Strip deprecated aliases and debug fields from a StreamChunk. - * - * @ag-ui/core's BaseEventSchema uses `.passthrough()`, so extra fields - * (model, content, usage, finishReason, etc.) are allowed and won't break - * spec validation. We only strip: - * - Deprecated field aliases (toolName, stepId, state) to nudge consumers - * toward spec names (toolCallName, stepName, snapshot) - * - The deprecated nested `error` object on RUN_ERROR (spec uses flat message/code) - * - `rawEvent` (debug payload, potentially large) + * All other fields pass through unchanged. @ag-ui/core's BaseEventSchema + * uses `.passthrough()`, so extra fields (model, content, usage, + * finishReason, toolName, stepId, etc.) are allowed and won't break + * spec validation or verifyEvents. */ export function stripToSpec(chunk: StreamChunk): StreamChunk { - const typeStrip = STRIP_BY_TYPE[chunk.type] - if (!typeStrip && ALWAYS_STRIP.size === 0) return chunk - - const result: Record = {} - - for (const [key, value] of Object.entries(chunk)) { - if (ALWAYS_STRIP.has(key)) continue - if (typeStrip?.has(key)) continue - result[key] = value + // Only strip the deprecated nested error object from RUN_ERROR + if (chunk.type === 'RUN_ERROR' && 'error' in chunk) { + const { error: _deprecated, ...rest } = chunk as Record + return rest as StreamChunk } - - return result as StreamChunk + return chunk } /** - * Middleware that strips deprecated aliases and debug fields from events. - * Should always be the LAST middleware in the chain. + * Middleware that ensures events are AG-UI spec compliant. + * Currently only strips the deprecated nested `error` object from RUN_ERROR. + * All other fields pass through unchanged (passthrough allowed by spec). */ export function stripToSpecMiddleware(): ChatMiddleware { return { diff --git a/packages/typescript/ai/tests/chat.test.ts b/packages/typescript/ai/tests/chat.test.ts index 5ae43465..2bcaec38 100644 --- a/packages/typescript/ai/tests/chat.test.ts +++ b/packages/typescript/ai/tests/chat.test.ts @@ -1319,7 +1319,7 @@ describe('chat()', () => { expect((runFinished as any).threadId).toBe('thread-1') }) - it('should strip deprecated toolName but keep extras on yielded events', async () => { + it('should include both toolCallName (spec) and toolName (deprecated) on TOOL_CALL_START', async () => { const { adapter } = createMockAdapter({ iterations: [ [ @@ -1353,10 +1353,9 @@ describe('chat()', () => { const toolStartChunks = chunks.filter((c) => c.type === 'TOOL_CALL_START') for (const chunk of toolStartChunks) { - // toolCallName should be present (spec field) + // Both spec and deprecated field present (passthrough) expect((chunk as any).toolCallName).toBe('get_weather') - // toolName should be stripped - expect('toolName' in chunk).toBe(false) + expect((chunk as any).toolName).toBe('get_weather') } }) diff --git a/packages/typescript/ai/tests/strip-to-spec-middleware.test.ts b/packages/typescript/ai/tests/strip-to-spec-middleware.test.ts index 672bfb47..6d63aa3e 100644 --- a/packages/typescript/ai/tests/strip-to-spec-middleware.test.ts +++ b/packages/typescript/ai/tests/strip-to-spec-middleware.test.ts @@ -2,104 +2,14 @@ import { describe, it, expect } from 'vitest' import { stripToSpec } from '../src/strip-to-spec-middleware' import type { StreamChunk } from '../src/types' -/** - * Helper to create a StreamChunk with the given type and fields. - */ -function makeChunk(type: string, fields: Record): StreamChunk { +function makeChunk( + type: string, + fields: Record, +): StreamChunk { return { type, timestamp: Date.now(), ...fields } as unknown as StreamChunk } describe('stripToSpec', () => { - // ========================================================================= - // Always stripped: rawEvent (debug payload, potentially large) - // ========================================================================= - - it('strips rawEvent from all events', () => { - const chunk = makeChunk('TEXT_MESSAGE_START', { - messageId: 'msg-1', - role: 'assistant', - rawEvent: { some: 'raw data' }, - model: 'gpt-4o', - }) - const result = stripToSpec(chunk) as Record - expect(result).not.toHaveProperty('rawEvent') - // model is kept (passthrough) - expect(result).toHaveProperty('model', 'gpt-4o') - expect(result).toHaveProperty('messageId', 'msg-1') - }) - - // ========================================================================= - // Deprecated aliases stripped: toolName, stepId, state, error (nested) - // ========================================================================= - - it('strips deprecated toolName from TOOL_CALL_START, keeps toolCallName', () => { - const chunk = makeChunk('TOOL_CALL_START', { - toolCallId: 'tc-1', - toolCallName: 'getTodos', - toolName: 'getTodos', - index: 0, - providerMetadata: { foo: 'bar' }, - model: 'gpt-4o', - }) - const result = stripToSpec(chunk) as Record - expect(result).not.toHaveProperty('toolName') - // These extras are kept (passthrough) - expect(result).toHaveProperty('toolCallName', 'getTodos') - expect(result).toHaveProperty('index', 0) - expect(result).toHaveProperty('providerMetadata') - expect(result).toHaveProperty('model', 'gpt-4o') - }) - - it('strips deprecated toolName from TOOL_CALL_END, keeps extras', () => { - const chunk = makeChunk('TOOL_CALL_END', { - toolCallId: 'tc-1', - toolName: 'getTodos', - toolCallName: 'getTodos', - input: { userId: '123' }, - result: '[{"id":"1","title":"Buy milk"}]', - model: 'gpt-4o', - }) - const result = stripToSpec(chunk) as Record - expect(result).not.toHaveProperty('toolName') - // These extras are kept (passthrough) - expect(result).toHaveProperty('toolCallName', 'getTodos') - expect(result).toHaveProperty('input') - expect(result).toHaveProperty('result') - expect(result).toHaveProperty('model', 'gpt-4o') - }) - - it('strips deprecated stepId from STEP_STARTED, keeps stepName and extras', () => { - const chunk = makeChunk('STEP_STARTED', { - stepName: 'thinking', - stepId: 'step-1', - stepType: 'thinking', - model: 'gpt-4o', - }) - const result = stripToSpec(chunk) as Record - expect(result).not.toHaveProperty('stepId') - // These extras are kept (passthrough) - expect(result).toHaveProperty('stepName', 'thinking') - expect(result).toHaveProperty('stepType', 'thinking') - expect(result).toHaveProperty('model', 'gpt-4o') - }) - - it('strips deprecated stepId from STEP_FINISHED, keeps extras', () => { - const chunk = makeChunk('STEP_FINISHED', { - stepName: 'thinking', - stepId: 'step-1', - delta: 'some thinking', - content: 'accumulated thinking', - model: 'gpt-4o', - }) - const result = stripToSpec(chunk) as Record - expect(result).not.toHaveProperty('stepId') - // These extras are kept (passthrough) - expect(result).toHaveProperty('stepName', 'thinking') - expect(result).toHaveProperty('delta', 'some thinking') - expect(result).toHaveProperty('content', 'accumulated thinking') - expect(result).toHaveProperty('model', 'gpt-4o') - }) - it('strips deprecated nested error from RUN_ERROR, keeps flat message/code', () => { const chunk = makeChunk('RUN_ERROR', { message: 'Something went wrong', @@ -114,106 +24,47 @@ describe('stripToSpec', () => { expect(result).toHaveProperty('model', 'gpt-4o') }) - it('strips deprecated state from STATE_SNAPSHOT, keeps snapshot', () => { - const chunk = makeChunk('STATE_SNAPSHOT', { - snapshot: { count: 42 }, - state: { count: 42 }, - model: 'gpt-4o', - }) - const result = stripToSpec(chunk) as Record - expect(result).not.toHaveProperty('state') - expect(result).toHaveProperty('snapshot') - expect(result).toHaveProperty('model', 'gpt-4o') - }) - - // ========================================================================= - // Extras preserved (passthrough allows them) - // ========================================================================= - - it('keeps model, content on TEXT_MESSAGE_CONTENT', () => { - const chunk = makeChunk('TEXT_MESSAGE_CONTENT', { - messageId: 'msg-1', - delta: 'Hello', - content: 'Hello World', - model: 'gpt-4o', - }) - const result = stripToSpec(chunk) as Record - expect(result).toHaveProperty('delta', 'Hello') - expect(result).toHaveProperty('content', 'Hello World') - expect(result).toHaveProperty('model', 'gpt-4o') - }) - - it('keeps args on TOOL_CALL_ARGS', () => { - const chunk = makeChunk('TOOL_CALL_ARGS', { + it('passes through all other events unchanged', () => { + const chunk = makeChunk('TOOL_CALL_START', { toolCallId: 'tc-1', - delta: '{"userId":', - args: '{"userId":', + toolCallName: 'getTodos', + toolName: 'getTodos', + index: 0, + providerMetadata: { foo: 'bar' }, model: 'gpt-4o', }) - const result = stripToSpec(chunk) as Record - expect(result).toHaveProperty('args', '{"userId":') - expect(result).toHaveProperty('delta', '{"userId":') - expect(result).toHaveProperty('model', 'gpt-4o') + const result = stripToSpec(chunk) + expect(result).toBe(chunk) // same reference, no copy }) - it('keeps finishReason and usage on RUN_FINISHED', () => { + it('keeps model, content, finishReason, usage, result, etc.', () => { const chunk = makeChunk('RUN_FINISHED', { runId: 'run-1', + threadId: 'thread-1', model: 'gpt-4o', finishReason: 'stop', usage: { promptTokens: 100, completionTokens: 50, totalTokens: 150 }, }) const result = stripToSpec(chunk) as Record + expect(result).toHaveProperty('model', 'gpt-4o') expect(result).toHaveProperty('finishReason', 'stop') expect(result).toHaveProperty('usage') - expect(result).toHaveProperty('model', 'gpt-4o') - }) - - it('keeps model on RUN_STARTED', () => { - const chunk = makeChunk('RUN_STARTED', { - runId: 'run-1', - threadId: 'thread-1', - model: 'gpt-4o', - }) - const result = stripToSpec(chunk) as Record - expect(result).toHaveProperty('model', 'gpt-4o') - expect(result).toHaveProperty('threadId', 'thread-1') - }) - - it('passes through REASONING events unchanged (except rawEvent)', () => { - const chunk = makeChunk('REASONING_MESSAGE_CONTENT', { - messageId: 'msg-1', - delta: 'Let me think...', - model: 'gpt-4o', - }) - const result = stripToSpec(chunk) as Record - expect(result).toHaveProperty('delta', 'Let me think...') - expect(result).toHaveProperty('model', 'gpt-4o') }) - it('passes through TOOL_CALL_RESULT unchanged (except rawEvent)', () => { - const chunk = makeChunk('TOOL_CALL_RESULT', { + it('keeps toolName, stepId, and other deprecated aliases (passthrough)', () => { + const chunk = makeChunk('TOOL_CALL_END', { toolCallId: 'tc-1', - messageId: 'msg-result-1', - content: '{"items":[]}', - role: 'tool', + toolCallName: 'getTodos', + toolName: 'getTodos', + input: { userId: '123' }, + result: '{"items":[]}', model: 'gpt-4o', }) const result = stripToSpec(chunk) as Record + expect(result).toHaveProperty('toolName', 'getTodos') + expect(result).toHaveProperty('toolCallName', 'getTodos') + expect(result).toHaveProperty('input') + expect(result).toHaveProperty('result') expect(result).toHaveProperty('model', 'gpt-4o') - expect(result).toHaveProperty('content', '{"items":[]}') - }) - - it('strips rawEvent even when combined with type-specific strips', () => { - const chunk = makeChunk('TOOL_CALL_START', { - toolCallId: 'tc-1', - toolCallName: 'foo', - toolName: 'foo', - rawEvent: { originalPayload: true }, - }) - const result = stripToSpec(chunk) as Record - expect(result).not.toHaveProperty('rawEvent') - expect(result).not.toHaveProperty('toolName') - expect(result).toHaveProperty('toolCallName', 'foo') }) }) From 3e881090bce537b859bff11a57cab54870c0bd5f Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 2 Apr 2026 13:27:40 +0200 Subject: [PATCH 25/30] fix: resolve type errors from @ag-ui/core Zod passthrough types Zod passthrough adds `& { [k: string]: unknown }` to inferred types, preventing TypeScript from narrowing the `type` discriminant in switch statements. Add explicit casts where needed. Also fix toolCallName -> toolName rename in realtime types to match consumer code. --- .../ai-openai/src/realtime/adapter.ts | 4 +-- .../src/activities/chat/middleware/compose.ts | 8 +++-- .../src/activities/chat/stream/processor.ts | 33 ++++++++++--------- packages/typescript/ai/src/realtime/types.ts | 2 +- .../ai/src/strip-to-spec-middleware.ts | 2 +- 5 files changed, 27 insertions(+), 22 deletions(-) diff --git a/packages/typescript/ai-openai/src/realtime/adapter.ts b/packages/typescript/ai-openai/src/realtime/adapter.ts index b120f2a1..36d6cea5 100644 --- a/packages/typescript/ai-openai/src/realtime/adapter.ts +++ b/packages/typescript/ai-openai/src/realtime/adapter.ts @@ -284,11 +284,11 @@ async function createWebRTCConnection( } try { const input = JSON.parse(args) - emit('tool_call', { toolCallId: callId, toolCallName: name, input }) + emit('tool_call', { toolCallId: callId, toolName: name, input }) } catch { emit('tool_call', { toolCallId: callId, - toolCallName: name, + toolName: name, input: args, }) } diff --git a/packages/typescript/ai/src/activities/chat/middleware/compose.ts b/packages/typescript/ai/src/activities/chat/middleware/compose.ts index e29f0f12..8e46e0fb 100644 --- a/packages/typescript/ai/src/activities/chat/middleware/compose.ts +++ b/packages/typescript/ai/src/activities/chat/middleware/compose.ts @@ -132,6 +132,8 @@ export class MiddlewareRunner { const nextChunks: Array = [] for (const c of chunks) { + // Cast: @ag-ui/core Zod passthrough types prevent direct `.type` access + const chunkType = (c as StreamChunk & { type: string }).type const result = await mw.onChunk(ctx, c) if (result === null) { // Drop this chunk @@ -139,7 +141,7 @@ export class MiddlewareRunner { aiEventClient.emit('middleware:chunk:transformed', { ...instrumentCtx(ctx), middlewareName: mw.name || 'unnamed', - originalChunkType: c.type, + originalChunkType: chunkType, resultCount: 0, wasDropped: true, }) @@ -155,7 +157,7 @@ export class MiddlewareRunner { aiEventClient.emit('middleware:chunk:transformed', { ...instrumentCtx(ctx), middlewareName: mw.name || 'unnamed', - originalChunkType: c.type, + originalChunkType: chunkType, resultCount: result.length, wasDropped: false, }) @@ -167,7 +169,7 @@ export class MiddlewareRunner { aiEventClient.emit('middleware:chunk:transformed', { ...instrumentCtx(ctx), middlewareName: mw.name || 'unnamed', - originalChunkType: c.type, + originalChunkType: chunkType, resultCount: 1, wasDropped: false, }) diff --git a/packages/typescript/ai/src/activities/chat/stream/processor.ts b/packages/typescript/ai/src/activities/chat/stream/processor.ts index dee7ba76..83a05d0b 100644 --- a/packages/typescript/ai/src/activities/chat/stream/processor.ts +++ b/packages/typescript/ai/src/activities/chat/stream/processor.ts @@ -445,54 +445,57 @@ export class StreamProcessor { }) } - switch (chunk.type) { + // Cast needed: @ag-ui/core Zod passthrough types add `& { [k: string]: unknown }` + // which prevents TypeScript from narrowing the `type` discriminant in switch. + const c = chunk as StreamChunk & { type: string } + switch (c.type) { // AG-UI Events case 'TEXT_MESSAGE_START': - this.handleTextMessageStartEvent(chunk) + this.handleTextMessageStartEvent(chunk as Extract) break case 'TEXT_MESSAGE_CONTENT': - this.handleTextMessageContentEvent(chunk) + this.handleTextMessageContentEvent(chunk as Extract) break case 'TEXT_MESSAGE_END': - this.handleTextMessageEndEvent(chunk) + this.handleTextMessageEndEvent(chunk as Extract) break case 'TOOL_CALL_START': - this.handleToolCallStartEvent(chunk) + this.handleToolCallStartEvent(chunk as Extract) break case 'TOOL_CALL_ARGS': - this.handleToolCallArgsEvent(chunk) + this.handleToolCallArgsEvent(chunk as Extract) break case 'TOOL_CALL_END': - this.handleToolCallEndEvent(chunk) + this.handleToolCallEndEvent(chunk as Extract) break case 'RUN_FINISHED': - this.handleRunFinishedEvent(chunk) + this.handleRunFinishedEvent(chunk as Extract) break case 'RUN_ERROR': - this.handleRunErrorEvent(chunk) + this.handleRunErrorEvent(chunk as Extract) break case 'STEP_FINISHED': - this.handleStepFinishedEvent(chunk) + this.handleStepFinishedEvent(chunk as Extract) break case 'MESSAGES_SNAPSHOT': - this.handleMessagesSnapshotEvent(chunk) + this.handleMessagesSnapshotEvent(chunk as Extract) break case 'CUSTOM': - this.handleCustomEvent(chunk) + this.handleCustomEvent(chunk as Extract) break case 'RUN_STARTED': - this.handleRunStartedEvent(chunk) + this.handleRunStartedEvent(chunk as Extract) break case 'REASONING_START': @@ -503,11 +506,11 @@ export class StreamProcessor { break case 'REASONING_MESSAGE_CONTENT': - this.handleReasoningMessageContentEvent(chunk) + this.handleReasoningMessageContentEvent(chunk as Extract) break case 'TOOL_CALL_RESULT': - this.handleToolCallResultEvent(chunk) + this.handleToolCallResultEvent(chunk as Extract) break default: diff --git a/packages/typescript/ai/src/realtime/types.ts b/packages/typescript/ai/src/realtime/types.ts index 97423b32..daaf6f57 100644 --- a/packages/typescript/ai/src/realtime/types.ts +++ b/packages/typescript/ai/src/realtime/types.ts @@ -257,7 +257,7 @@ export interface RealtimeEventPayloads { isFinal: boolean } audio_chunk: { data: ArrayBuffer; sampleRate: number } - tool_call: { toolCallId: string; toolCallName: string; input: unknown } + tool_call: { toolCallId: string; toolName: string; input: unknown } message_complete: { message: RealtimeMessage } interrupted: { messageId?: string } error: { error: Error } diff --git a/packages/typescript/ai/src/strip-to-spec-middleware.ts b/packages/typescript/ai/src/strip-to-spec-middleware.ts index c4648702..49471d87 100644 --- a/packages/typescript/ai/src/strip-to-spec-middleware.ts +++ b/packages/typescript/ai/src/strip-to-spec-middleware.ts @@ -12,7 +12,7 @@ import type { StreamChunk } from './types' */ export function stripToSpec(chunk: StreamChunk): StreamChunk { // Only strip the deprecated nested error object from RUN_ERROR - if (chunk.type === 'RUN_ERROR' && 'error' in chunk) { + if ((chunk as StreamChunk & { type: string }).type === 'RUN_ERROR' && 'error' in chunk) { const { error: _deprecated, ...rest } = chunk as Record return rest as StreamChunk } From f8bca899d34e0e7613c648610575e051c8693797 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:29:04 +0000 Subject: [PATCH 26/30] ci: apply automated fixes --- .../src/activities/chat/stream/processor.ts | 56 ++++++++++++++----- .../ai/src/strip-to-spec-middleware.ts | 5 +- .../ai/tests/strip-to-spec-middleware.test.ts | 5 +- 3 files changed, 47 insertions(+), 19 deletions(-) diff --git a/packages/typescript/ai/src/activities/chat/stream/processor.ts b/packages/typescript/ai/src/activities/chat/stream/processor.ts index 83a05d0b..61cb8a9d 100644 --- a/packages/typescript/ai/src/activities/chat/stream/processor.ts +++ b/packages/typescript/ai/src/activities/chat/stream/processor.ts @@ -451,51 +451,75 @@ export class StreamProcessor { switch (c.type) { // AG-UI Events case 'TEXT_MESSAGE_START': - this.handleTextMessageStartEvent(chunk as Extract) + this.handleTextMessageStartEvent( + chunk as Extract, + ) break case 'TEXT_MESSAGE_CONTENT': - this.handleTextMessageContentEvent(chunk as Extract) + this.handleTextMessageContentEvent( + chunk as Extract, + ) break case 'TEXT_MESSAGE_END': - this.handleTextMessageEndEvent(chunk as Extract) + this.handleTextMessageEndEvent( + chunk as Extract, + ) break case 'TOOL_CALL_START': - this.handleToolCallStartEvent(chunk as Extract) + this.handleToolCallStartEvent( + chunk as Extract, + ) break case 'TOOL_CALL_ARGS': - this.handleToolCallArgsEvent(chunk as Extract) + this.handleToolCallArgsEvent( + chunk as Extract, + ) break case 'TOOL_CALL_END': - this.handleToolCallEndEvent(chunk as Extract) + this.handleToolCallEndEvent( + chunk as Extract, + ) break case 'RUN_FINISHED': - this.handleRunFinishedEvent(chunk as Extract) + this.handleRunFinishedEvent( + chunk as Extract, + ) break case 'RUN_ERROR': - this.handleRunErrorEvent(chunk as Extract) + this.handleRunErrorEvent( + chunk as Extract, + ) break case 'STEP_FINISHED': - this.handleStepFinishedEvent(chunk as Extract) + this.handleStepFinishedEvent( + chunk as Extract, + ) break case 'MESSAGES_SNAPSHOT': - this.handleMessagesSnapshotEvent(chunk as Extract) + this.handleMessagesSnapshotEvent( + chunk as Extract, + ) break case 'CUSTOM': - this.handleCustomEvent(chunk as Extract) + this.handleCustomEvent( + chunk as Extract, + ) break case 'RUN_STARTED': - this.handleRunStartedEvent(chunk as Extract) + this.handleRunStartedEvent( + chunk as Extract, + ) break case 'REASONING_START': @@ -506,11 +530,15 @@ export class StreamProcessor { break case 'REASONING_MESSAGE_CONTENT': - this.handleReasoningMessageContentEvent(chunk as Extract) + this.handleReasoningMessageContentEvent( + chunk as Extract, + ) break case 'TOOL_CALL_RESULT': - this.handleToolCallResultEvent(chunk as Extract) + this.handleToolCallResultEvent( + chunk as Extract, + ) break default: diff --git a/packages/typescript/ai/src/strip-to-spec-middleware.ts b/packages/typescript/ai/src/strip-to-spec-middleware.ts index 49471d87..1863ebec 100644 --- a/packages/typescript/ai/src/strip-to-spec-middleware.ts +++ b/packages/typescript/ai/src/strip-to-spec-middleware.ts @@ -12,7 +12,10 @@ import type { StreamChunk } from './types' */ export function stripToSpec(chunk: StreamChunk): StreamChunk { // Only strip the deprecated nested error object from RUN_ERROR - if ((chunk as StreamChunk & { type: string }).type === 'RUN_ERROR' && 'error' in chunk) { + if ( + (chunk as StreamChunk & { type: string }).type === 'RUN_ERROR' && + 'error' in chunk + ) { const { error: _deprecated, ...rest } = chunk as Record return rest as StreamChunk } diff --git a/packages/typescript/ai/tests/strip-to-spec-middleware.test.ts b/packages/typescript/ai/tests/strip-to-spec-middleware.test.ts index 6d63aa3e..0099fad2 100644 --- a/packages/typescript/ai/tests/strip-to-spec-middleware.test.ts +++ b/packages/typescript/ai/tests/strip-to-spec-middleware.test.ts @@ -2,10 +2,7 @@ import { describe, it, expect } from 'vitest' import { stripToSpec } from '../src/strip-to-spec-middleware' import type { StreamChunk } from '../src/types' -function makeChunk( - type: string, - fields: Record, -): StreamChunk { +function makeChunk(type: string, fields: Record): StreamChunk { return { type, timestamp: Date.now(), ...fields } as unknown as StreamChunk } From 2c83fbd396ef4a21e7d53ba6b5aee49eec3d9a7b Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 2 Apr 2026 14:59:36 +0200 Subject: [PATCH 27/30] chore(ai): bump @ag-ui/core from 0.0.48 to 0.0.49 Removes rxjs from the transitive dependency tree. All exported types and EventType enum values are identical between versions. --- packages/typescript/ai/package.json | 2 +- pnpm-lock.yaml | 18 +++++------------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/packages/typescript/ai/package.json b/packages/typescript/ai/package.json index 417cb639..25358511 100644 --- a/packages/typescript/ai/package.json +++ b/packages/typescript/ai/package.json @@ -55,7 +55,7 @@ "embeddings" ], "dependencies": { - "@ag-ui/core": "0.0.48", + "@ag-ui/core": "0.0.49", "@tanstack/ai-event-client": "workspace:*", "partial-json": "^0.1.7" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9ca45f9..1ec41379 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -808,8 +808,8 @@ importers: packages/typescript/ai: dependencies: '@ag-ui/core': - specifier: 0.0.48 - version: 0.0.48 + specifier: 0.0.49 + version: 0.0.49 '@tanstack/ai-event-client': specifier: workspace:* version: link:../ai-event-client @@ -1770,8 +1770,8 @@ packages: '@acemir/cssom@0.9.29': resolution: {integrity: sha512-G90x0VW+9nW4dFajtjCoT+NM0scAfH9Mb08IcjgFHYbfiL/lU04dTF9JuVOi3/OH+DJCQdcIseSXkdCB9Ky6JA==} - '@ag-ui/core@0.0.48': - resolution: {integrity: sha512-HP4wO+0+j8Rkshn0eiV0XAfrh7zeTbvZsQ2URopmMXVK3f6DC2I3jw3IN1ZGaJwSuzPtch5T3NC4jrj/PazVeA==} + '@ag-ui/core@0.0.49': + resolution: {integrity: sha512-9ywypwjUGtIvTxJ2eKQjhPZgLnSFAfNK7vZUcT7Bz4ur4yAIB+lAFtzvu7VDYe6jsUx/6N/71Dh4R0zX5woNVw==} '@alcyone-labs/zod-to-json-schema@4.0.10': resolution: {integrity: sha512-TFsSpAPToqmqmT85SGHXuxoCwEeK9zUDvn512O9aBVvWRhSuy+VvAXZkifzsdllD3ncF0ZjUrf4MpBwIEixdWQ==} @@ -9049,9 +9049,6 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - rxjs@7.8.1: - resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} - rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} @@ -10618,9 +10615,8 @@ snapshots: '@acemir/cssom@0.9.29': {} - '@ag-ui/core@0.0.48': + '@ag-ui/core@0.0.49': dependencies: - rxjs: 7.8.1 zod: 3.25.76 '@alcyone-labs/zod-to-json-schema@4.0.10(zod@4.2.1)': @@ -19519,10 +19515,6 @@ snapshots: dependencies: queue-microtask: 1.2.3 - rxjs@7.8.1: - dependencies: - tslib: 2.8.1 - rxjs@7.8.2: dependencies: tslib: 2.8.1 From 3c080439782cef848c0d81cefb74979ee603e9b1 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 2 Apr 2026 15:00:04 +0200 Subject: [PATCH 28/30] fix: CR fixes for AG-UI core interop - Use this.threadId in createSyntheticFinishedEvent instead of regenerating a new ID on each call - Add defensive delta guard in handleReasoningMessageContentEvent matching sibling handler patterns - Prefer spec chunk.message over deprecated chunk.error in devtools middleware, generation client, and video generation client - Add flat message field to synthesized RUN_ERROR in connection adapters - Fix processChunk JSDoc listing RUN_STARTED as ignored (it has a handler) - Fix comment referencing toolName when code uses toolCallName - Document RUN_ERROR in stream-generation-result @returns - Add meaningful assertions to TOOL_CALL_RESULT processor test - Clarify threadId test describes adapter passthrough behavior --- .../ai-client/src/connection-adapters.ts | 4 ++++ .../ai-client/src/generation-client.ts | 2 +- .../ai-client/src/video-generation-client.ts | 2 +- .../ai-event-client/src/devtools-middleware.ts | 6 ++++-- .../typescript/ai/src/activities/chat/index.ts | 4 ++-- .../ai/src/activities/chat/stream/processor.ts | 5 +++-- .../src/activities/stream-generation-result.ts | 2 +- packages/typescript/ai/tests/chat.test.ts | 2 +- .../ai/tests/stream-processor.test.ts | 17 ++++++++++++++--- 9 files changed, 31 insertions(+), 13 deletions(-) diff --git a/packages/typescript/ai-client/src/connection-adapters.ts b/packages/typescript/ai-client/src/connection-adapters.ts index b5de861e..4f4feafc 100644 --- a/packages/typescript/ai-client/src/connection-adapters.ts +++ b/packages/typescript/ai-client/src/connection-adapters.ts @@ -200,6 +200,10 @@ export function normalizeConnectionAdapter( push({ type: 'RUN_ERROR', timestamp: Date.now(), + message: + err instanceof Error + ? err.message + : 'Unknown error in connect()', error: { message: err instanceof Error diff --git a/packages/typescript/ai-client/src/generation-client.ts b/packages/typescript/ai-client/src/generation-client.ts index 2b4b1a07..33b35ed9 100644 --- a/packages/typescript/ai-client/src/generation-client.ts +++ b/packages/typescript/ai-client/src/generation-client.ts @@ -177,7 +177,7 @@ export class GenerationClient< break } case 'RUN_ERROR': { - throw new Error(chunk.error?.message ?? chunk.message) + throw new Error(chunk.message ?? chunk.error?.message ?? 'An error occurred') } } } diff --git a/packages/typescript/ai-client/src/video-generation-client.ts b/packages/typescript/ai-client/src/video-generation-client.ts index 1fa7a22c..9337bba4 100644 --- a/packages/typescript/ai-client/src/video-generation-client.ts +++ b/packages/typescript/ai-client/src/video-generation-client.ts @@ -214,7 +214,7 @@ export class VideoGenerationClient { break } case 'RUN_ERROR': { - throw new Error(chunk.error?.message ?? chunk.message) + throw new Error(chunk.message ?? chunk.error?.message ?? 'An error occurred') } } } diff --git a/packages/typescript/ai-event-client/src/devtools-middleware.ts b/packages/typescript/ai-event-client/src/devtools-middleware.ts index 408968ff..6762d5f7 100644 --- a/packages/typescript/ai-event-client/src/devtools-middleware.ts +++ b/packages/typescript/ai-event-client/src/devtools-middleware.ts @@ -313,11 +313,13 @@ export function devtoolsMiddleware(): DevtoolsChatMiddleware { break } case 'RUN_ERROR': { - const err = chunk.error as { message?: string } | undefined + const errorMessage = chunk.message + || (chunk.error as { message?: string } | undefined)?.message + || 'Unknown error' aiEventClient.emit('text:chunk:error', { ...base, messageId: localMessageId || undefined, - error: err?.message ?? String(chunk.error), + error: errorMessage, timestamp: Date.now(), }) break diff --git a/packages/typescript/ai/src/activities/chat/index.ts b/packages/typescript/ai/src/activities/chat/index.ts index 461781df..b5caf058 100644 --- a/packages/typescript/ai/src/activities/chat/index.ts +++ b/packages/typescript/ai/src/activities/chat/index.ts @@ -321,7 +321,7 @@ class TextEngine< // Initialize middleware — devtools first, strip-to-spec always last. // handleStreamChunk processes raw chunks BEFORE middleware, so internal - // state management sees extended fields (finishReason, delta, toolName, etc.). + // state management sees extended fields (finishReason, delta, toolCallName, etc.). // The strip middleware ensures the yielded public stream is AG-UI spec-compliant. const allMiddleware = [ devtoolsMiddleware(), @@ -1201,7 +1201,7 @@ class TextEngine< return { type: 'RUN_FINISHED', runId: this.createId('pending'), - threadId: this.params.threadId ?? this.createId('thread'), + threadId: this.threadId, model: this.params.model, timestamp: Date.now(), finishReason: 'tool_calls', diff --git a/packages/typescript/ai/src/activities/chat/stream/processor.ts b/packages/typescript/ai/src/activities/chat/stream/processor.ts index 61cb8a9d..def1194c 100644 --- a/packages/typescript/ai/src/activities/chat/stream/processor.ts +++ b/packages/typescript/ai/src/activities/chat/stream/processor.ts @@ -431,7 +431,7 @@ export class StreamProcessor { * * Central dispatch for all AG-UI events. Each event type maps to a specific * handler. Events not listed in the switch are intentionally ignored - * (RUN_STARTED, STEP_STARTED, STATE_DELTA). + * (STEP_STARTED, STATE_SNAPSHOT, STATE_DELTA). * * @see docs/chat-architecture.md#adapter-contract — Expected event types and ordering */ @@ -1222,7 +1222,8 @@ export class StreamProcessor { ) state.hasSeenReasoningEvents = true - state.thinkingContent = state.thinkingContent + chunk.delta + const delta = chunk.delta || '' + state.thinkingContent = state.thinkingContent + delta this.messages = updateThinkingPart( this.messages, diff --git a/packages/typescript/ai/src/activities/stream-generation-result.ts b/packages/typescript/ai/src/activities/stream-generation-result.ts index 54f03706..7994a79b 100644 --- a/packages/typescript/ai/src/activities/stream-generation-result.ts +++ b/packages/typescript/ai/src/activities/stream-generation-result.ts @@ -19,7 +19,7 @@ function createId(prefix: string): string { * * @param generator - An async function that performs the generation and returns the result * @param options - Optional configuration (runId, threadId) - * @returns An AsyncIterable of StreamChunks with RUN_STARTED, CUSTOM(generation:result), and RUN_FINISHED events + * @returns An AsyncIterable of StreamChunks with RUN_STARTED, CUSTOM(generation:result), and RUN_FINISHED events on success, or RUN_STARTED and RUN_ERROR on failure */ export async function* streamGenerationResult( generator: () => Promise, diff --git a/packages/typescript/ai/tests/chat.test.ts b/packages/typescript/ai/tests/chat.test.ts index 2bcaec38..dd1d362c 100644 --- a/packages/typescript/ai/tests/chat.test.ts +++ b/packages/typescript/ai/tests/chat.test.ts @@ -1289,7 +1289,7 @@ describe('chat()', () => { // AG-UI spec compliance (threadId, strip middleware) // ========================================================================== describe('AG-UI spec compliance', () => { - it('should include threadId on RUN_STARTED and RUN_FINISHED events', async () => { + it('should pass through adapter-generated threadId on RUN_STARTED and RUN_FINISHED events', async () => { const { adapter } = createMockAdapter({ iterations: [ [ diff --git a/packages/typescript/ai/tests/stream-processor.test.ts b/packages/typescript/ai/tests/stream-processor.test.ts index 9b680358..02145e9e 100644 --- a/packages/typescript/ai/tests/stream-processor.test.ts +++ b/packages/typescript/ai/tests/stream-processor.test.ts @@ -3080,7 +3080,7 @@ describe('StreamProcessor', () => { // TOOL_CALL_RESULT event // ========================================================================== describe('TOOL_CALL_RESULT event', () => { - it('should process TOOL_CALL_RESULT without errors', () => { + it('should create tool-result part and update tool-call output', () => { const events = spyEvents() const processor = new StreamProcessor({ events }) @@ -3102,8 +3102,19 @@ describe('StreamProcessor', () => { ) processor.processChunk(ev.runFinished('tool_calls')) - // TOOL_CALL_RESULT is a no-op in StreamProcessor, but should not throw - expect(processor.getMessages()).toBeDefined() + const messages = processor.getMessages() + const toolCallPart = messages[0]?.parts.find( + (p) => p.type === 'tool-call', + ) as ToolCallPart + expect((toolCallPart as any).output).toEqual({ temp: 72 }) + + const toolResultPart = messages[0]?.parts.find( + (p) => p.type === 'tool-result', + ) as ToolResultPart + expect(toolResultPart).toBeDefined() + expect(toolResultPart.toolCallId).toBe('tc-1') + expect(toolResultPart.content).toBe('{"temp": 72}') + expect(toolResultPart.state).toBe('complete') }) }) }) From f3246a317280181cb38a87461e49e05973127c4a Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:01:15 +0000 Subject: [PATCH 29/30] ci: apply automated fixes --- packages/typescript/ai-client/src/connection-adapters.ts | 4 +--- packages/typescript/ai-client/src/generation-client.ts | 4 +++- .../typescript/ai-client/src/video-generation-client.ts | 4 +++- .../typescript/ai-event-client/src/devtools-middleware.ts | 7 ++++--- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/typescript/ai-client/src/connection-adapters.ts b/packages/typescript/ai-client/src/connection-adapters.ts index 4f4feafc..bd51a4a5 100644 --- a/packages/typescript/ai-client/src/connection-adapters.ts +++ b/packages/typescript/ai-client/src/connection-adapters.ts @@ -201,9 +201,7 @@ export function normalizeConnectionAdapter( type: 'RUN_ERROR', timestamp: Date.now(), message: - err instanceof Error - ? err.message - : 'Unknown error in connect()', + err instanceof Error ? err.message : 'Unknown error in connect()', error: { message: err instanceof Error diff --git a/packages/typescript/ai-client/src/generation-client.ts b/packages/typescript/ai-client/src/generation-client.ts index 33b35ed9..1e5022b2 100644 --- a/packages/typescript/ai-client/src/generation-client.ts +++ b/packages/typescript/ai-client/src/generation-client.ts @@ -177,7 +177,9 @@ export class GenerationClient< break } case 'RUN_ERROR': { - throw new Error(chunk.message ?? chunk.error?.message ?? 'An error occurred') + throw new Error( + chunk.message ?? chunk.error?.message ?? 'An error occurred', + ) } } } diff --git a/packages/typescript/ai-client/src/video-generation-client.ts b/packages/typescript/ai-client/src/video-generation-client.ts index 9337bba4..23ddb733 100644 --- a/packages/typescript/ai-client/src/video-generation-client.ts +++ b/packages/typescript/ai-client/src/video-generation-client.ts @@ -214,7 +214,9 @@ export class VideoGenerationClient { break } case 'RUN_ERROR': { - throw new Error(chunk.message ?? chunk.error?.message ?? 'An error occurred') + throw new Error( + chunk.message ?? chunk.error?.message ?? 'An error occurred', + ) } } } diff --git a/packages/typescript/ai-event-client/src/devtools-middleware.ts b/packages/typescript/ai-event-client/src/devtools-middleware.ts index 6762d5f7..e574a92c 100644 --- a/packages/typescript/ai-event-client/src/devtools-middleware.ts +++ b/packages/typescript/ai-event-client/src/devtools-middleware.ts @@ -313,9 +313,10 @@ export function devtoolsMiddleware(): DevtoolsChatMiddleware { break } case 'RUN_ERROR': { - const errorMessage = chunk.message - || (chunk.error as { message?: string } | undefined)?.message - || 'Unknown error' + const errorMessage = + chunk.message || + (chunk.error as { message?: string } | undefined)?.message || + 'Unknown error' aiEventClient.emit('text:chunk:error', { ...base, messageId: localMessageId || undefined, From 14a78f3c718bc2b64ebd485c902e8be0d82f072f Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 2 Apr 2026 16:23:02 +0200 Subject: [PATCH 30/30] fix(ai-client): use cast for RUN_ERROR message to satisfy eslint chunk.message is typed as required string by @ag-ui/core but may be absent at runtime from events constructed via as-unknown casts. Cast to string|undefined to allow the || fallback chain while keeping the no-unnecessary-condition rule happy. --- packages/typescript/ai-client/src/generation-client.ts | 9 ++++++--- .../typescript/ai-client/src/video-generation-client.ts | 9 ++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/typescript/ai-client/src/generation-client.ts b/packages/typescript/ai-client/src/generation-client.ts index 1e5022b2..ac09a740 100644 --- a/packages/typescript/ai-client/src/generation-client.ts +++ b/packages/typescript/ai-client/src/generation-client.ts @@ -177,9 +177,12 @@ export class GenerationClient< break } case 'RUN_ERROR': { - throw new Error( - chunk.message ?? chunk.error?.message ?? 'An error occurred', - ) + // Prefer spec `message`; fall back to deprecated `error.message` + const msg = + (chunk.message as string | undefined) || + chunk.error?.message || + 'An error occurred' + throw new Error(msg) } } } diff --git a/packages/typescript/ai-client/src/video-generation-client.ts b/packages/typescript/ai-client/src/video-generation-client.ts index 23ddb733..914a2262 100644 --- a/packages/typescript/ai-client/src/video-generation-client.ts +++ b/packages/typescript/ai-client/src/video-generation-client.ts @@ -214,9 +214,12 @@ export class VideoGenerationClient { break } case 'RUN_ERROR': { - throw new Error( - chunk.message ?? chunk.error?.message ?? 'An error occurred', - ) + // Prefer spec `message`; fall back to deprecated `error.message` + const msg = + (chunk.message as string | undefined) || + chunk.error?.message || + 'An error occurred' + throw new Error(msg) } } }