From 070efb915bb2799d77caf80923753087bb11974d Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Mon, 16 Feb 2026 17:16:46 -0800 Subject: [PATCH 1/5] custom event changes --- .claude/settings.local.json | 9 +- .../ts-react-chat/src/lib/guitar-tools.ts | 8 +- .../ts-react-chat/src/routes/api.tanchat.ts | 25 +++-- examples/ts-react-chat/src/routes/index.tsx | 7 ++ .../typescript/ai-client/src/chat-client.ts | 21 +++++ packages/typescript/ai-client/src/types.ts | 14 +++ packages/typescript/ai-react/src/use-chat.ts | 1 + packages/typescript/ai-solid/src/use-chat.ts | 1 + .../ai-svelte/src/create-chat.svelte.ts | 1 + packages/typescript/ai-vue/src/use-chat.ts | 1 + .../ai/src/activities/chat/index.ts | 57 +++++++++++- .../src/activities/chat/stream/processor.ts | 16 ++++ .../src/activities/chat/tools/tool-calls.ts | 93 ++++++++++++++++++- .../activities/chat/tools/tool-definition.ts | 3 + packages/typescript/ai/src/types.ts | 30 +++++- packages/typescript/ai/tests/chat.test.ts | 5 +- .../ai/tests/tool-call-manager.test.ts | 44 ++++++--- 17 files changed, 304 insertions(+), 32 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index fd03337a3..35f87a53a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -7,7 +7,14 @@ "Bash(pnpm test:lib:*)", "Bash(pnpm typecheck:*)", "Bash(pnpm build:*)", - "Bash(find:*)" + "Bash(find:*)", + "Bash(ls:*)", + "Bash(grep:*)", + "Bash(pnpm --filter @tanstack/ai test:lib:*)", + "Bash(pnpm test:types:*)", + "Bash(pnpm test:eslint:*)", + "Bash(pnpm --filter @tanstack/ai test:eslint:*)", + "Bash(git -C /Users/jherr/projects/tanstack/ai checkout:*)" ] } } diff --git a/examples/ts-react-chat/src/lib/guitar-tools.ts b/examples/ts-react-chat/src/lib/guitar-tools.ts index cbff42ee8..32c9a4ee3 100644 --- a/examples/ts-react-chat/src/lib/guitar-tools.ts +++ b/examples/ts-react-chat/src/lib/guitar-tools.ts @@ -20,7 +20,13 @@ export const getGuitarsToolDef = toolDefinition({ }) // Server implementation -export const getGuitars = getGuitarsToolDef.server(() => guitars) +export const getGuitars = getGuitarsToolDef.server((_, context) => { + context?.emitCustomEvent('tool:progress', { + tool: 'getGuitars', + message: `Fetching ${guitars.length} guitars from inventory`, + }) + return guitars +}) // Tool definition for guitar recommendation export const recommendGuitarToolDef = toolDefinition({ diff --git a/examples/ts-react-chat/src/routes/api.tanchat.ts b/examples/ts-react-chat/src/routes/api.tanchat.ts index f011927c2..45454c32d 100644 --- a/examples/ts-react-chat/src/routes/api.tanchat.ts +++ b/examples/ts-react-chat/src/routes/api.tanchat.ts @@ -50,13 +50,24 @@ Step 1: Call getGuitars() Step 2: Call recommendGuitar(id: "6") Step 3: Done - do NOT add any text after calling recommendGuitar ` -const addToCartToolServer = addToCartToolDef.server((args) => ({ - success: true, - cartId: 'CART_' + Date.now(), - guitarId: args.guitarId, - quantity: args.quantity, - totalItems: args.quantity, -})) +const addToCartToolServer = addToCartToolDef.server((args, context) => { + context?.emitCustomEvent('tool:progress', { + tool: 'addToCart', + message: `Adding ${args.quantity}x guitar ${args.guitarId} to cart`, + }) + const cartId = 'CART_' + Date.now() + context?.emitCustomEvent('tool:progress', { + tool: 'addToCart', + message: `Cart ${cartId} created successfully`, + }) + return { + success: true, + cartId, + guitarId: args.guitarId, + quantity: args.quantity, + totalItems: args.quantity, + } +}) export const Route = createFileRoute('/api/tanchat')({ server: { diff --git a/examples/ts-react-chat/src/routes/index.tsx b/examples/ts-react-chat/src/routes/index.tsx index 3463c6610..ddd725747 100644 --- a/examples/ts-react-chat/src/routes/index.tsx +++ b/examples/ts-react-chat/src/routes/index.tsx @@ -269,6 +269,13 @@ function ChatPage() { connection: fetchServerSentEvents('/api/tanchat'), tools, body, + onCustomEvent: (eventType, data, context) => { + console.log( + `[CustomEvent] ${eventType}`, + data, + context.toolCallId ? `(tool call: ${context.toolCallId})` : '', + ) + }, }) const [input, setInput] = useState('') diff --git a/packages/typescript/ai-client/src/chat-client.ts b/packages/typescript/ai-client/src/chat-client.ts index 65554a447..27078d5e8 100644 --- a/packages/typescript/ai-client/src/chat-client.ts +++ b/packages/typescript/ai-client/src/chat-client.ts @@ -51,6 +51,11 @@ export class ChatClient { onLoadingChange: (isLoading: boolean) => void onErrorChange: (error: Error | undefined) => void onStatusChange: (status: ChatClientState) => void + onCustomEvent: ( + eventType: string, + data: unknown, + context: { toolCallId?: string }, + ) => void } } @@ -78,6 +83,7 @@ export class ChatClient { onLoadingChange: options.onLoadingChange || (() => {}), onErrorChange: options.onErrorChange || (() => {}), onStatusChange: options.onStatusChange || (() => {}), + onCustomEvent: options.onCustomEvent || (() => {}), }, } @@ -198,6 +204,13 @@ export class ChatClient { ) } }, + onCustomEvent: ( + eventType: string, + data: unknown, + context: { toolCallId?: string }, + ) => { + this.callbacksRef.current.onCustomEvent(eventType, data, context) + }, }, }) @@ -684,6 +697,11 @@ export class ChatClient { onChunk?: (chunk: StreamChunk) => void onFinish?: (message: UIMessage) => void onError?: (error: Error) => void + onCustomEvent?: ( + eventType: string, + data: unknown, + context: { toolCallId?: string }, + ) => void }): void { if (options.connection !== undefined) { this.connection = options.connection @@ -709,5 +727,8 @@ export class ChatClient { if (options.onError !== undefined) { this.callbacksRef.current.onError = options.onError } + if (options.onCustomEvent !== undefined) { + this.callbacksRef.current.onCustomEvent = options.onCustomEvent + } } } diff --git a/packages/typescript/ai-client/src/types.ts b/packages/typescript/ai-client/src/types.ts index 985725481..b9bd0a46c 100644 --- a/packages/typescript/ai-client/src/types.ts +++ b/packages/typescript/ai-client/src/types.ts @@ -239,6 +239,20 @@ export interface ChatClientOptions< */ onStatusChange?: (status: ChatClientState) => void + /** + * Callback when a custom event is received from a server-side tool. + * Custom events are emitted by tools using `context.emitCustomEvent()` during execution. + * + * @param eventType - The name of the custom event + * @param data - The event payload data + * @param context - Additional context including the toolCallId that emitted the event + */ + onCustomEvent?: ( + eventType: string, + data: unknown, + context: { toolCallId?: string }, + ) => void + /** * Client-side tools with execution logic * When provided, tools with execute functions will be called automatically diff --git a/packages/typescript/ai-react/src/use-chat.ts b/packages/typescript/ai-react/src/use-chat.ts index 2cdc02d11..b49ac0514 100644 --- a/packages/typescript/ai-react/src/use-chat.ts +++ b/packages/typescript/ai-react/src/use-chat.ts @@ -64,6 +64,7 @@ export function useChat = any>( optionsRef.current.onError?.(error) }, tools: optionsRef.current.tools, + onCustomEvent: optionsRef.current.onCustomEvent, streamProcessor: options.streamProcessor, onMessagesChange: (newMessages: Array>) => { setMessages(newMessages) diff --git a/packages/typescript/ai-solid/src/use-chat.ts b/packages/typescript/ai-solid/src/use-chat.ts index 77d0edf96..c006871ef 100644 --- a/packages/typescript/ai-solid/src/use-chat.ts +++ b/packages/typescript/ai-solid/src/use-chat.ts @@ -47,6 +47,7 @@ export function useChat = any>( options.onError?.(err) }, tools: options.tools, + onCustomEvent: options.onCustomEvent, streamProcessor: options.streamProcessor, onMessagesChange: (newMessages: Array>) => { setMessages(newMessages) diff --git a/packages/typescript/ai-svelte/src/create-chat.svelte.ts b/packages/typescript/ai-svelte/src/create-chat.svelte.ts index 5354ae113..f9889e754 100644 --- a/packages/typescript/ai-svelte/src/create-chat.svelte.ts +++ b/packages/typescript/ai-svelte/src/create-chat.svelte.ts @@ -67,6 +67,7 @@ export function createChat = any>( options.onError?.(err) }, tools: options.tools, + onCustomEvent: options.onCustomEvent, streamProcessor: options.streamProcessor, onMessagesChange: (newMessages: Array>) => { messages = newMessages diff --git a/packages/typescript/ai-vue/src/use-chat.ts b/packages/typescript/ai-vue/src/use-chat.ts index 6042fc535..3ee0881c7 100644 --- a/packages/typescript/ai-vue/src/use-chat.ts +++ b/packages/typescript/ai-vue/src/use-chat.ts @@ -37,6 +37,7 @@ export function useChat = any>( options.onError?.(err) }, tools: options.tools, + onCustomEvent: options.onCustomEvent, streamProcessor: options.streamProcessor, onMessagesChange: (newMessages: Array>) => { messages.value = newMessages diff --git a/packages/typescript/ai/src/activities/chat/index.ts b/packages/typescript/ai/src/activities/chat/index.ts index 4595671e4..3964bc1da 100644 --- a/packages/typescript/ai/src/activities/chat/index.ts +++ b/packages/typescript/ai/src/activities/chat/index.ts @@ -24,6 +24,7 @@ import type { AnyTextAdapter } from './adapter' import type { AgentLoopStrategy, ConstrainedModelMessage, + CustomEvent, InferSchemaType, ModelMessage, RunFinishedEvent, @@ -601,13 +602,17 @@ class TextEngine< const { approvals, clientToolResults } = this.collectClientState() - const executionResult = await executeToolCalls( + const generator = executeToolCalls( pendingToolCalls, this.tools, approvals, clientToolResults, + (eventName, data) => this.createCustomEventChunk(eventName, data), ) + // Consume the async generator, yielding custom events and collecting the return value + const executionResult = yield* this.drainToolCallGenerator(generator) + if ( executionResult.needsApproval.length > 0 || executionResult.needsClientExecution.length > 0 @@ -660,13 +665,17 @@ class TextEngine< const { approvals, clientToolResults } = this.collectClientState() - const executionResult = await executeToolCalls( + const generator = executeToolCalls( toolCalls, this.tools, approvals, clientToolResults, + (eventName, data) => this.createCustomEventChunk(eventName, data), ) + // Consume the async generator, yielding custom events and collecting the return value + const executionResult = yield* this.drainToolCallGenerator(generator) + if ( executionResult.needsApproval.length > 0 || executionResult.needsClientExecution.length > 0 @@ -1052,6 +1061,50 @@ class TextEngine< } } + /** + * Drain an executeToolCalls async generator, yielding any CustomEvent chunks + * and returning the final ExecuteToolCallsResult. + */ + private async *drainToolCallGenerator( + generator: AsyncGenerator< + CustomEvent, + { + results: Array + needsApproval: Array + needsClientExecution: Array + }, + void + >, + ): AsyncGenerator< + StreamChunk, + { + results: Array + needsApproval: Array + needsClientExecution: Array + }, + void + > { + let next = await generator.next() + while (!next.done) { + yield next.value + next = await generator.next() + } + return next.value + } + + private createCustomEventChunk( + eventName: string, + data: Record, + ): CustomEvent { + return { + type: 'CUSTOM', + timestamp: Date.now(), + model: this.params.model, + name: eventName, + data, + } + } + private createId(prefix: string): string { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}` } diff --git a/packages/typescript/ai/src/activities/chat/stream/processor.ts b/packages/typescript/ai/src/activities/chat/stream/processor.ts index 96d95865d..a91a46d89 100644 --- a/packages/typescript/ai/src/activities/chat/stream/processor.ts +++ b/packages/typescript/ai/src/activities/chat/stream/processor.ts @@ -73,6 +73,13 @@ export interface StreamProcessorEvents { approvalId: string }) => void + // Custom events from server-side tools + onCustomEvent?: ( + eventType: string, + data: unknown, + context: { toolCallId?: string }, + ) => void + // Granular events for UI optimization (character-by-character, state tracking) onTextUpdate?: (messageId: string, content: string) => void onToolCallStateChange?: ( @@ -820,6 +827,8 @@ export class StreamProcessor { toolName, input, }) + + return } // Handle approval requests @@ -849,7 +858,14 @@ export class StreamProcessor { input, approvalId: approval.id, }) + + return } + + // Forward all other custom events to the callback + this.events.onCustomEvent?.(chunk.name, chunk.data, { + toolCallId: (chunk.data as any)?.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 6ea512841..0c622b9ec 100644 --- a/packages/typescript/ai/src/activities/chat/tools/tool-calls.ts +++ b/packages/typescript/ai/src/activities/chat/tools/tool-calls.ts @@ -1,5 +1,6 @@ import { isStandardSchema, parseWithStandardSchema } from './schema-converter' import type { + CustomEvent, ModelMessage, RunFinishedEvent, Tool, @@ -7,6 +8,7 @@ import type { ToolCallArgsEvent, ToolCallEndEvent, ToolCallStartEvent, + ToolExecutionContext, } from '../../../types' /** @@ -250,7 +252,46 @@ interface ExecuteToolCallsResult { } /** - * Execute tool calls based on their configuration + * Helper that runs a tool execution promise while polling for pending custom events. + * Yields any custom events that are emitted during execution, then returns the + * execution result. + */ +async function* executeWithEventPolling( + executionPromise: Promise, + pendingEvents: Array, +): AsyncGenerator { + // Use an object to track mutable state across the async boundary + const state = { done: false, result: undefined as T } + const executionWithFlag = executionPromise.then((r) => { + state.done = true + state.result = r + return r + }) + + while (!state.done) { + // Wait for either the execution to complete or a short timeout + await Promise.race([ + executionWithFlag, + new Promise((resolve) => setTimeout(resolve, 10)), + ]) + + // Flush any pending events + while (pendingEvents.length > 0) { + yield pendingEvents.shift()! + } + } + + // Final flush in case events were emitted right at completion + while (pendingEvents.length > 0) { + yield pendingEvents.shift()! + } + + return state.result +} + +/** + * Execute tool calls based on their configuration. + * Yields CustomEvent chunks during tool execution for real-time progress updates. * * Handles three cases: * 1. Client tools (no execute) - request client to execute @@ -261,13 +302,18 @@ interface ExecuteToolCallsResult { * @param tools - Available tools with their configurations * @param approvals - Map of approval decisions (approval.id -> approved boolean) * @param clientResults - Map of client-side execution results (toolCallId -> result) + * @param createCustomEventChunk - Factory to create CustomEvent chunks (optional) */ -export async function executeToolCalls( +export async function* executeToolCalls( toolCalls: Array, tools: ReadonlyArray, approvals: Map = new Map(), clientResults: Map = new Map(), -): Promise { + createCustomEventChunk?: ( + eventName: string, + data: Record, + ) => CustomEvent, +): AsyncGenerator { const results: Array = [] const needsApproval: Array = [] const needsClientExecution: Array = [] @@ -326,6 +372,29 @@ export async function executeToolCalls( } } + // Create a ToolExecutionContext for this tool call with event emission + const pendingEvents: Array = [] + const context: ToolExecutionContext = { + toolCallId: toolCall.id, + emitCustomEvent: (eventName: string, data: Record) => { + if (createCustomEventChunk) { + pendingEvents.push( + createCustomEventChunk(eventName, { + ...data, + toolCallId: toolCall.id, + }), + ) + } + }, + } + + // Helper to flush any pending events + function* flushEvents(): Generator { + while (pendingEvents.length > 0) { + yield pendingEvents.shift()! + } + } + // CASE 1: Client-side tool (no execute function) if (!tool.execute) { // Check if tool needs approval @@ -402,8 +471,15 @@ export async function executeToolCalls( // Execute after approval const startTime = Date.now() try { - let result = await tool.execute(input) + const executionPromise = Promise.resolve( + tool.execute(input, context), + ) + let result = yield* executeWithEventPolling( + executionPromise, + pendingEvents, + ) const duration = Date.now() - startTime + yield* flushEvents() // Validate output against outputSchema if provided (for Standard Schema compliant schemas) if ( @@ -426,6 +502,7 @@ export async function executeToolCalls( }) } catch (error: unknown) { const duration = Date.now() - startTime + yield* flushEvents() const message = error instanceof Error ? error.message : 'Unknown error' results.push({ @@ -460,8 +537,13 @@ export async function executeToolCalls( // CASE 3: Normal server tool - execute immediately const startTime = Date.now() try { - let result = await tool.execute(input) + const executionPromise = Promise.resolve(tool.execute(input, context)) + let result = yield* executeWithEventPolling( + executionPromise, + pendingEvents, + ) const duration = Date.now() - startTime + yield* flushEvents() // Validate output against outputSchema if provided (for Standard Schema compliant schemas) if ( @@ -482,6 +564,7 @@ export async function executeToolCalls( }) } catch (error: unknown) { const duration = Date.now() - startTime + yield* flushEvents() const message = error instanceof Error ? error.message : 'Unknown error' results.push({ toolCallId: toolCall.id, diff --git a/packages/typescript/ai/src/activities/chat/tools/tool-definition.ts b/packages/typescript/ai/src/activities/chat/tools/tool-definition.ts index 69633c49b..ca82b8cd3 100644 --- a/packages/typescript/ai/src/activities/chat/tools/tool-definition.ts +++ b/packages/typescript/ai/src/activities/chat/tools/tool-definition.ts @@ -4,6 +4,7 @@ import type { JSONSchema, SchemaInput, Tool, + ToolExecutionContext, } from '../../../types' /** @@ -112,6 +113,7 @@ export interface ToolDefinition< server: ( execute: ( args: InferSchemaType, + context?: ToolExecutionContext, ) => Promise> | InferSchemaType, ) => ServerTool @@ -193,6 +195,7 @@ export function toolDefinition< server( execute: ( args: InferSchemaType, + context?: ToolExecutionContext, ) => Promise> | InferSchemaType, ): ServerTool { return { diff --git a/packages/typescript/ai/src/types.ts b/packages/typescript/ai/src/types.ts index 4d7ca6e52..75a50e4d0 100644 --- a/packages/typescript/ai/src/types.ts +++ b/packages/typescript/ai/src/types.ts @@ -344,6 +344,34 @@ export type ConstrainedModelMessage< content: ConstrainedContent } +/** + * Context passed to tool execute functions, providing capabilities like + * emitting custom events during execution. + */ +export interface ToolExecutionContext { + /** The ID of the tool call being executed */ + toolCallId?: string + /** + * Emit a custom event during tool execution. + * Events are streamed to the client in real-time as AG-UI CUSTOM events. + * + * @param eventName - Name of the custom event + * @param data - Event payload data + * + * @example + * ```ts + * const tool = toolDefinition({ ... }).server(async (args, context) => { + * context?.emitCustomEvent('progress', { step: 1, total: 3 }) + * // ... do work ... + * context?.emitCustomEvent('progress', { step: 2, total: 3 }) + * // ... do more work ... + * return result + * }) + * ``` + */ + emitCustomEvent: (eventName: string, data: Record) => void +} + /** * Tool/Function definition for function calling. * @@ -460,7 +488,7 @@ export interface Tool< * return weather; // Can return object or string * } */ - execute?: (args: any) => Promise | any + execute?: (args: any, context?: ToolExecutionContext) => Promise | any /** If true, tool execution requires user approval before running. Works with both server and client tools. */ needsApproval?: boolean diff --git a/packages/typescript/ai/tests/chat.test.ts b/packages/typescript/ai/tests/chat.test.ts index d47939c94..a5dfcb270 100644 --- a/packages/typescript/ai/tests/chat.test.ts +++ b/packages/typescript/ai/tests/chat.test.ts @@ -330,7 +330,10 @@ describe('chat()', () => { // Tool was executed expect(executeSpy).toHaveBeenCalledTimes(1) - expect(executeSpy).toHaveBeenCalledWith({ city: 'NYC' }) + expect(executeSpy).toHaveBeenCalledWith( + { city: 'NYC' }, + expect.objectContaining({ toolCallId: 'call_1' }), + ) // A TOOL_CALL_END chunk with result should have been yielded const toolEndChunks = chunks.filter( diff --git a/packages/typescript/ai/tests/tool-call-manager.test.ts b/packages/typescript/ai/tests/tool-call-manager.test.ts index 4e47953b7..69b5cceab 100644 --- a/packages/typescript/ai/tests/tool-call-manager.test.ts +++ b/packages/typescript/ai/tests/tool-call-manager.test.ts @@ -416,13 +416,25 @@ describe('executeToolCalls', () => { } } + /** Helper to drain the async generator from executeToolCalls and return its result */ + async function drainExecuteToolCalls( + ...args: Parameters + ) { + const gen = executeToolCalls(...args) + let next = await gen.next() + while (!next.done) { + next = await gen.next() + } + return next.value + } + describe('client tool with needsApproval', () => { it('should request approval when no approval decision exists', async () => { const toolCalls = [ makeToolCall('call_1', 'delete_local_data', '{"key":"myKey"}'), ] - const result = await executeToolCalls( + const result = await drainExecuteToolCalls( toolCalls, [clientToolWithApproval], new Map(), @@ -442,7 +454,7 @@ describe('executeToolCalls', () => { ] const approvals = new Map([['approval_call_1', true]]) - const result = await executeToolCalls( + const result = await drainExecuteToolCalls( toolCalls, [clientToolWithApproval], approvals, @@ -464,7 +476,7 @@ describe('executeToolCalls', () => { const approvals = new Map([['approval_call_1', true]]) const clientResults = new Map([['call_1', { deleted: true }]]) - const result = await executeToolCalls( + const result = await drainExecuteToolCalls( toolCalls, [clientToolWithApproval], approvals, @@ -484,7 +496,7 @@ describe('executeToolCalls', () => { ] const approvals = new Map([['approval_call_1', false]]) - const result = await executeToolCalls( + const result = await drainExecuteToolCalls( toolCalls, [clientToolWithApproval], approvals, @@ -522,7 +534,7 @@ describe('executeToolCalls', () => { ], ]) - const result = await executeToolCalls( + const result = await drainExecuteToolCalls( toolCalls, [clientToolWithApproval], approvals, @@ -547,7 +559,7 @@ describe('executeToolCalls', () => { makeToolCall('call_1', 'get_local_data', '{"key":"myKey"}'), ] - const result = await executeToolCalls( + const result = await drainExecuteToolCalls( toolCalls, [clientToolWithoutApproval], new Map(), @@ -565,7 +577,7 @@ describe('executeToolCalls', () => { ] const clientResults = new Map([['call_1', { value: 'stored_data' }]]) - const result = await executeToolCalls( + const result = await drainExecuteToolCalls( toolCalls, [clientToolWithoutApproval], new Map(), @@ -584,7 +596,7 @@ describe('executeToolCalls', () => { makeToolCall('call_1', 'delete_record', '{"id":"rec_123"}'), ] - const result = await executeToolCalls( + const result = await drainExecuteToolCalls( toolCalls, [serverToolWithApproval], new Map(), @@ -602,7 +614,7 @@ describe('executeToolCalls', () => { ] const approvals = new Map([['approval_call_1', true]]) - const result = await executeToolCalls( + const result = await drainExecuteToolCalls( toolCalls, [serverToolWithApproval], approvals, @@ -611,9 +623,10 @@ describe('executeToolCalls', () => { expect(result.results).toHaveLength(1) expect(result.results[0]?.result).toEqual({ deleted: true }) - expect(serverToolWithApproval.execute).toHaveBeenCalledWith({ - id: 'rec_123', - }) + expect(serverToolWithApproval.execute).toHaveBeenCalledWith( + { id: 'rec_123' }, + expect.objectContaining({ toolCallId: 'call_1' }), + ) }) }) @@ -628,7 +641,7 @@ describe('executeToolCalls', () => { const toolCalls = [makeToolCall('call_1', 'simple_tool', '')] - const result = await executeToolCalls( + const result = await drainExecuteToolCalls( toolCalls, [tool], new Map(), @@ -636,7 +649,10 @@ describe('executeToolCalls', () => { ) expect(result.results).toHaveLength(1) - expect(tool.execute).toHaveBeenCalledWith({}) + expect(tool.execute).toHaveBeenCalledWith( + {}, + expect.objectContaining({ toolCallId: 'call_1' }), + ) }) }) }) From 9efd52083045b55415ec12bbfbae154f3ff2f829 Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Mon, 16 Feb 2026 17:30:43 -0800 Subject: [PATCH 2/5] more unit tests --- .../ai-client/tests/chat-client.test.ts | 183 ++++++++++++++ .../typescript/ai-client/tests/test-utils.ts | 30 +++ .../tests/custom-events-integration.test.ts | 225 ++++++++++++++++++ .../ai/tests/stream-processor.test.ts | 136 +++++++++++ 4 files changed, 574 insertions(+) create mode 100644 packages/typescript/ai/tests/custom-events-integration.test.ts diff --git a/packages/typescript/ai-client/tests/chat-client.test.ts b/packages/typescript/ai-client/tests/chat-client.test.ts index 279603783..94f6b62e9 100644 --- a/packages/typescript/ai-client/tests/chat-client.test.ts +++ b/packages/typescript/ai-client/tests/chat-client.test.ts @@ -5,6 +5,7 @@ import { createTextChunks, createThinkingChunks, createToolCallChunks, + createCustomEventChunks, } from './test-utils' import type { UIMessage } from '../src/types' @@ -901,4 +902,186 @@ describe('ChatClient', () => { expect((userMessageEvent?.[1] as any)?.content).toBe('What is this?') }) }) + + describe('custom events', () => { + it('should call onCustomEvent callback for arbitrary custom events', async () => { + const chunks = createCustomEventChunks([ + { name: 'progress-update', data: { progress: 50, step: 'processing' } }, + { name: 'tool-status', data: { toolCallId: 'tc-1', status: 'running' } }, + ]) + const adapter = createMockConnectionAdapter({ chunks }) + + const onCustomEvent = vi.fn() + const client = new ChatClient({ connection: adapter, onCustomEvent }) + + await client.sendMessage('Hello') + + expect(onCustomEvent).toHaveBeenCalledTimes(2) + expect(onCustomEvent).toHaveBeenNthCalledWith( + 1, + 'progress-update', + { progress: 50, step: 'processing' }, + { toolCallId: undefined }, + ) + expect(onCustomEvent).toHaveBeenNthCalledWith( + 2, + 'tool-status', + { toolCallId: 'tc-1', status: 'running' }, + { toolCallId: 'tc-1' }, + ) + }) + + it('should extract toolCallId from custom event data and pass in context', async () => { + const chunks = createCustomEventChunks([ + { + name: 'external-api-call', + data: { toolCallId: 'tc-123', url: 'https://api.example.com', method: 'POST' }, + }, + ]) + const adapter = createMockConnectionAdapter({ chunks }) + + const onCustomEvent = vi.fn() + const client = new ChatClient({ connection: adapter, onCustomEvent }) + + await client.sendMessage('Test') + + expect(onCustomEvent).toHaveBeenCalledWith( + 'external-api-call', + { toolCallId: 'tc-123', url: 'https://api.example.com', method: 'POST' }, + { toolCallId: 'tc-123' }, + ) + }) + + it('should handle custom events with no data', async () => { + const chunks = createCustomEventChunks([ + { name: 'simple-notification' }, + ]) + const adapter = createMockConnectionAdapter({ chunks }) + + const onCustomEvent = vi.fn() + const client = new ChatClient({ connection: adapter, onCustomEvent }) + + await client.sendMessage('Test') + + expect(onCustomEvent).toHaveBeenCalledWith( + 'simple-notification', + undefined, + { toolCallId: undefined }, + ) + }) + + it('should NOT call onCustomEvent for system events like tool-input-available', async () => { + const chunks = createToolCallChunks([ + { id: 'tc-1', name: 'testTool', arguments: '{}' }, + ]) + const adapter = createMockConnectionAdapter({ chunks }) + + const onCustomEvent = vi.fn() + const client = new ChatClient({ connection: adapter, onCustomEvent }) + + await client.sendMessage('Test tool call') + + // Should not have been called for tool-input-available system event + expect(onCustomEvent).not.toHaveBeenCalled() + }) + + it('should work when onCustomEvent is not provided', async () => { + const chunks = createCustomEventChunks([ + { name: 'some-event', data: { info: 'test' } }, + ]) + const adapter = createMockConnectionAdapter({ chunks }) + + const client = new ChatClient({ connection: adapter }) + + // Should not throw error when onCustomEvent is undefined + await expect(client.sendMessage('Test')).resolves.not.toThrow() + }) + + it('should allow updating onCustomEvent via updateOptions', async () => { + const chunks = createCustomEventChunks([ + { name: 'test-event', data: { value: 42 } }, + ]) + const adapter = createMockConnectionAdapter({ chunks }) + + const client = new ChatClient({ connection: adapter }) + + const onCustomEvent = vi.fn() + client.updateOptions({ onCustomEvent }) + + await client.sendMessage('Test') + + expect(onCustomEvent).toHaveBeenCalledWith( + 'test-event', + { value: 42 }, + { toolCallId: undefined }, + ) + }) + + it('should handle multiple different custom events in sequence', async () => { + const chunks = createCustomEventChunks([ + { name: 'step-1', data: { stage: 'init' } }, + { name: 'step-2', data: { stage: 'process', toolCallId: 'tc-1' } }, + { name: 'step-3', data: { stage: 'complete' } }, + ]) + const adapter = createMockConnectionAdapter({ chunks }) + + const onCustomEvent = vi.fn() + const client = new ChatClient({ connection: adapter, onCustomEvent }) + + await client.sendMessage('Multi-step process') + + expect(onCustomEvent).toHaveBeenCalledTimes(3) + expect(onCustomEvent).toHaveBeenNthCalledWith( + 1, + 'step-1', + { stage: 'init' }, + { toolCallId: undefined }, + ) + expect(onCustomEvent).toHaveBeenNthCalledWith( + 2, + 'step-2', + { stage: 'process', toolCallId: 'tc-1' }, + { toolCallId: 'tc-1' }, + ) + expect(onCustomEvent).toHaveBeenNthCalledWith( + 3, + 'step-3', + { stage: 'complete' }, + { toolCallId: undefined }, + ) + }) + + it('should preserve event data exactly as received from stream', async () => { + const complexEventData = { + nested: { object: { with: 'values' } }, + array: [1, 2, 3], + null_value: null, + boolean: true, + number: 42, + } + + const chunks = createCustomEventChunks([ + { name: 'complex-data-event', data: complexEventData }, + ]) + const adapter = createMockConnectionAdapter({ chunks }) + + const onCustomEvent = vi.fn() + const client = new ChatClient({ connection: adapter, onCustomEvent }) + + await client.sendMessage('Complex data test') + + expect(onCustomEvent).toHaveBeenCalledWith( + 'complex-data-event', + complexEventData, + { toolCallId: undefined }, + ) + + // Verify the data object is preserved exactly + const actualData = onCustomEvent.mock.calls[0]?.[1] + expect(actualData).toEqual(complexEventData) + expect(actualData.nested.object.with).toBe('values') + expect(actualData.array).toEqual([1, 2, 3]) + expect(actualData.null_value).toBeNull() + }) + }) }) diff --git a/packages/typescript/ai-client/tests/test-utils.ts b/packages/typescript/ai-client/tests/test-utils.ts index 9d0b3d364..aedc59298 100644 --- a/packages/typescript/ai-client/tests/test-utils.ts +++ b/packages/typescript/ai-client/tests/test-utils.ts @@ -142,6 +142,36 @@ export function createTextChunks( return chunks } +/** + * Helper to create custom event chunks + */ +export function createCustomEventChunks( + events: Array<{ name: string; data?: unknown }>, + model: string = 'test', +): Array { + const chunks: Array = [] + + for (const event of events) { + chunks.push({ + type: 'CUSTOM', + model, + timestamp: Date.now(), + name: event.name, + data: event.data, + }) + } + + chunks.push({ + type: 'RUN_FINISHED', + runId: 'run-1', + model, + timestamp: Date.now(), + finishReason: 'stop', + }) + + return chunks +} + /** * Helper to create tool call chunks (AG-UI format) * Optionally includes tool-input-available chunks to trigger onToolCall diff --git a/packages/typescript/ai/tests/custom-events-integration.test.ts b/packages/typescript/ai/tests/custom-events-integration.test.ts new file mode 100644 index 000000000..738804420 --- /dev/null +++ b/packages/typescript/ai/tests/custom-events-integration.test.ts @@ -0,0 +1,225 @@ +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 { z } from 'zod' + +describe('Custom Events Integration', () => { + it('should emit custom events from tool execution context', async () => { + const onCustomEvent = vi.fn() + + // Create a test tool that emits custom events + const testTool = toolDefinition({ + name: 'testTool', + description: 'A test tool that emits custom events', + inputSchema: z.object({ + message: z.string(), + }), + }).server(async (args, context) => { + // Emit progress event + context?.emitCustomEvent('tool:progress', { + tool: 'testTool', + progress: 25, + message: 'Starting processing...', + }) + + // Simulate some work + await new Promise(resolve => setTimeout(resolve, 10)) + + // Emit another progress event + context?.emitCustomEvent('tool:progress', { + tool: 'testTool', + progress: 75, + message: 'Almost done...', + }) + + // Emit completion event + context?.emitCustomEvent('tool:complete', { + tool: 'testTool', + result: 'success', + duration: 20, + }) + + return { processed: args.message } + }) + + const processor = new StreamProcessor({ + events: { + onCustomEvent, + onMessagesChange: vi.fn(), + onStreamStart: vi.fn(), + onStreamEnd: vi.fn(), + onError: vi.fn(), + onToolCall: vi.fn(), + onApprovalRequest: vi.fn(), + onTextUpdate: vi.fn(), + onToolCallStateChange: vi.fn(), + onThinkingUpdate: vi.fn(), + } + }) + + // Prepare assistant message + processor.prepareAssistantMessage() + + // Simulate tool call sequence + processor.processChunk({ + type: 'TOOL_CALL_START', + toolCallId: 'tc-1', + toolName: 'testTool', + timestamp: Date.now(), + index: 0, + }) + + processor.processChunk({ + type: 'TOOL_CALL_ARGS', + toolCallId: 'tc-1', + timestamp: Date.now(), + delta: '{"message": "Hello World"}', + }) + + processor.processChunk({ + 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 + if (toolExecuteFunc) { + const mockContext = { + toolCallId: 'tc-1', + emitCustomEvent: (eventName: string, data: any) => { + // This simulates the real behavior where emitCustomEvent creates CUSTOM stream chunks + processor.processChunk({ + type: 'CUSTOM', + name: eventName, + data: { ...data, toolCallId: 'tc-1' }, + timestamp: Date.now(), + }) + } + } + + await toolExecuteFunc({ message: 'Hello World' }, mockContext) + } + + // Verify custom events were emitted + expect(onCustomEvent).toHaveBeenCalledTimes(3) + + expect(onCustomEvent).toHaveBeenNthCalledWith(1, 'tool:progress', + { tool: 'testTool', progress: 25, message: 'Starting processing...', toolCallId: 'tc-1' }, + { toolCallId: 'tc-1' } + ) + + expect(onCustomEvent).toHaveBeenNthCalledWith(2, 'tool:progress', + { tool: 'testTool', progress: 75, message: 'Almost done...', toolCallId: 'tc-1' }, + { toolCallId: 'tc-1' } + ) + + expect(onCustomEvent).toHaveBeenNthCalledWith(3, 'tool:complete', + { tool: 'testTool', result: 'success', duration: 20, toolCallId: 'tc-1' }, + { toolCallId: 'tc-1' } + ) + }) + + it('should handle custom events without toolCallId in context', async () => { + const onCustomEvent = vi.fn() + + const processor = new StreamProcessor({ + events: { + onCustomEvent, + onMessagesChange: vi.fn(), + onStreamStart: vi.fn(), + onStreamEnd: vi.fn(), + onError: vi.fn(), + onToolCall: vi.fn(), + onApprovalRequest: vi.fn(), + onTextUpdate: vi.fn(), + onToolCallStateChange: vi.fn(), + onThinkingUpdate: vi.fn(), + } + }) + + // Emit custom event without toolCallId + processor.processChunk({ + type: 'CUSTOM', + name: 'system:status', + data: { status: 'ready', version: '1.0.0' }, + timestamp: Date.now(), + }) + + expect(onCustomEvent).toHaveBeenCalledWith( + 'system:status', + { status: 'ready', version: '1.0.0' }, + { toolCallId: undefined } + ) + }) + + it('should not forward system custom events to onCustomEvent callback', async () => { + const onCustomEvent = vi.fn() + const onToolCall = vi.fn() + const onApprovalRequest = vi.fn() + + const processor = new StreamProcessor({ + events: { + onCustomEvent, + onToolCall, + onApprovalRequest, + onMessagesChange: vi.fn(), + onStreamStart: vi.fn(), + onStreamEnd: vi.fn(), + onError: vi.fn(), + onTextUpdate: vi.fn(), + onToolCallStateChange: vi.fn(), + onThinkingUpdate: vi.fn(), + } + }) + + processor.prepareAssistantMessage() + + // System event: tool-input-available + processor.processChunk({ + type: 'CUSTOM', + name: 'tool-input-available', + data: { + toolCallId: 'tc-1', + toolName: 'testTool', + input: { test: true }, + }, + timestamp: Date.now(), + }) + + // System event: approval-requested + processor.processChunk({ + type: 'CUSTOM', + name: 'approval-requested', + data: { + toolCallId: 'tc-2', + toolName: 'dangerousTool', + input: { action: 'delete' }, + approval: { id: 'approval-1', needsApproval: true }, + }, + timestamp: Date.now(), + }) + + // Custom event (should be forwarded) + processor.processChunk({ + type: 'CUSTOM', + name: 'user:custom-event', + data: { message: 'This should be forwarded' }, + timestamp: Date.now(), + }) + + // System events should trigger their specific handlers, not onCustomEvent + expect(onToolCall).toHaveBeenCalledTimes(1) + expect(onApprovalRequest).toHaveBeenCalledTimes(1) + + // Only the user custom event should be forwarded + expect(onCustomEvent).toHaveBeenCalledTimes(1) + expect(onCustomEvent).toHaveBeenCalledWith( + 'user:custom-event', + { message: 'This should be forwarded' }, + { toolCallId: undefined } + ) + }) +}) \ No newline at end of file diff --git a/packages/typescript/ai/tests/stream-processor.test.ts b/packages/typescript/ai/tests/stream-processor.test.ts index ddb7f8129..0d6684736 100644 --- a/packages/typescript/ai/tests/stream-processor.test.ts +++ b/packages/typescript/ai/tests/stream-processor.test.ts @@ -85,6 +85,7 @@ function spyEvents(): MockedEvents { onError: vi.fn(), onToolCall: vi.fn(), onApprovalRequest: vi.fn(), + onCustomEvent: vi.fn(), onTextUpdate: vi.fn(), onToolCallStateChange: vi.fn(), onThinkingUpdate: vi.fn(), @@ -1077,6 +1078,141 @@ describe('StreamProcessor', () => { }) }) + // ========================================================================== + // Custom event dispatch + // ========================================================================== + describe('custom event dispatch', () => { + it('should forward arbitrary custom events to onCustomEvent callback', () => { + const events = spyEvents() + const processor = new StreamProcessor({ events }) + processor.prepareAssistantMessage() + + const customData = { progress: 50, message: 'Processing...' } + processor.processChunk(ev.custom('my-custom-event', customData)) + + expect(events.onCustomEvent).toHaveBeenCalledTimes(1) + expect(events.onCustomEvent).toHaveBeenCalledWith( + 'my-custom-event', + customData, + { toolCallId: undefined }, + ) + }) + + it('should extract toolCallId from custom event data and include in context', () => { + const events = spyEvents() + const processor = new StreamProcessor({ events }) + processor.prepareAssistantMessage() + + const customData = { toolCallId: 'tc-1', status: 'in-progress' } + processor.processChunk(ev.custom('tool-progress', customData)) + + expect(events.onCustomEvent).toHaveBeenCalledWith( + 'tool-progress', + customData, + { toolCallId: 'tc-1' }, + ) + }) + + it('should handle custom events with no data', () => { + const events = spyEvents() + const processor = new StreamProcessor({ events }) + processor.prepareAssistantMessage() + + processor.processChunk(ev.custom('simple-event')) + + expect(events.onCustomEvent).toHaveBeenCalledWith( + 'simple-event', + undefined, + { toolCallId: undefined }, + ) + }) + + it('should NOT forward system custom events (tool-input-available) to onCustomEvent', () => { + const events = spyEvents() + const processor = new StreamProcessor({ events }) + processor.prepareAssistantMessage() + + // Create a tool call first + processor.processChunk(ev.toolStart('tc-1', 'clientTool')) + processor.processChunk(ev.toolEnd('tc-1', 'clientTool', { input: {} })) + + processor.processChunk( + ev.custom('tool-input-available', { + toolCallId: 'tc-1', + toolName: 'clientTool', + input: {}, + }), + ) + + // Should fire onToolCall but NOT onCustomEvent + expect(events.onToolCall).toHaveBeenCalledTimes(1) + expect(events.onCustomEvent).not.toHaveBeenCalled() + }) + + it('should NOT forward system custom events (approval-requested) to onCustomEvent', () => { + const events = spyEvents() + const processor = new StreamProcessor({ events }) + processor.prepareAssistantMessage() + + // Create a tool call first + processor.processChunk(ev.toolStart('tc-1', 'dangerousTool')) + processor.processChunk(ev.toolEnd('tc-1', 'dangerousTool', { input: {} })) + + processor.processChunk( + ev.custom('approval-requested', { + toolCallId: 'tc-1', + toolName: 'dangerousTool', + input: {}, + approval: { id: 'approval-1', needsApproval: true }, + }), + ) + + // Should fire onApprovalRequest but NOT onCustomEvent + expect(events.onApprovalRequest).toHaveBeenCalledTimes(1) + expect(events.onCustomEvent).not.toHaveBeenCalled() + }) + + it('should work when onCustomEvent handler is not provided', () => { + // No events object provided - should not throw + const processor = new StreamProcessor() + processor.prepareAssistantMessage() + + expect(() => { + processor.processChunk(ev.custom('test-event', { data: 'test' })) + }).not.toThrow() + }) + + it('should handle multiple different custom events in sequence', () => { + const events = spyEvents() + const processor = new StreamProcessor({ events }) + processor.prepareAssistantMessage() + + processor.processChunk(ev.custom('event-1', { step: 1 })) + processor.processChunk(ev.custom('event-2', { step: 2 })) + processor.processChunk(ev.custom('event-3', { step: 3, toolCallId: 'tc-1' })) + + expect(events.onCustomEvent).toHaveBeenCalledTimes(3) + expect(events.onCustomEvent).toHaveBeenNthCalledWith( + 1, + 'event-1', + { step: 1 }, + { toolCallId: undefined }, + ) + expect(events.onCustomEvent).toHaveBeenNthCalledWith( + 2, + 'event-2', + { step: 2 }, + { toolCallId: undefined }, + ) + expect(events.onCustomEvent).toHaveBeenNthCalledWith( + 3, + 'event-3', + { step: 3, toolCallId: 'tc-1' }, + { toolCallId: 'tc-1' }, + ) + }) + }) + // ========================================================================== // areAllToolsComplete // ========================================================================== From c3782e5bb7a7f70b0e8544678001d234fa9ccf4a Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 17 Feb 2026 03:18:11 +0000 Subject: [PATCH 3/5] ci: apply automated fixes --- .../ai-client/tests/chat-client.test.ts | 21 +++-- .../tests/custom-events-integration.test.ts | 78 +++++++++++-------- .../ai/tests/stream-processor.test.ts | 4 +- 3 files changed, 65 insertions(+), 38 deletions(-) diff --git a/packages/typescript/ai-client/tests/chat-client.test.ts b/packages/typescript/ai-client/tests/chat-client.test.ts index 94f6b62e9..2976d4945 100644 --- a/packages/typescript/ai-client/tests/chat-client.test.ts +++ b/packages/typescript/ai-client/tests/chat-client.test.ts @@ -907,7 +907,10 @@ describe('ChatClient', () => { it('should call onCustomEvent callback for arbitrary custom events', async () => { const chunks = createCustomEventChunks([ { name: 'progress-update', data: { progress: 50, step: 'processing' } }, - { name: 'tool-status', data: { toolCallId: 'tc-1', status: 'running' } }, + { + name: 'tool-status', + data: { toolCallId: 'tc-1', status: 'running' }, + }, ]) const adapter = createMockConnectionAdapter({ chunks }) @@ -935,7 +938,11 @@ describe('ChatClient', () => { const chunks = createCustomEventChunks([ { name: 'external-api-call', - data: { toolCallId: 'tc-123', url: 'https://api.example.com', method: 'POST' }, + data: { + toolCallId: 'tc-123', + url: 'https://api.example.com', + method: 'POST', + }, }, ]) const adapter = createMockConnectionAdapter({ chunks }) @@ -947,15 +954,17 @@ describe('ChatClient', () => { expect(onCustomEvent).toHaveBeenCalledWith( 'external-api-call', - { toolCallId: 'tc-123', url: 'https://api.example.com', method: 'POST' }, + { + toolCallId: 'tc-123', + url: 'https://api.example.com', + method: 'POST', + }, { toolCallId: 'tc-123' }, ) }) it('should handle custom events with no data', async () => { - const chunks = createCustomEventChunks([ - { name: 'simple-notification' }, - ]) + const chunks = createCustomEventChunks([{ name: 'simple-notification' }]) const adapter = createMockConnectionAdapter({ chunks }) const onCustomEvent = vi.fn() diff --git a/packages/typescript/ai/tests/custom-events-integration.test.ts b/packages/typescript/ai/tests/custom-events-integration.test.ts index 738804420..1bee12fcd 100644 --- a/packages/typescript/ai/tests/custom-events-integration.test.ts +++ b/packages/typescript/ai/tests/custom-events-integration.test.ts @@ -6,7 +6,7 @@ import { z } from 'zod' describe('Custom Events Integration', () => { it('should emit custom events from tool execution context', async () => { const onCustomEvent = vi.fn() - + // Create a test tool that emits custom events const testTool = toolDefinition({ name: 'testTool', @@ -21,24 +21,24 @@ describe('Custom Events Integration', () => { progress: 25, message: 'Starting processing...', }) - + // Simulate some work - await new Promise(resolve => setTimeout(resolve, 10)) - + await new Promise((resolve) => setTimeout(resolve, 10)) + // Emit another progress event context?.emitCustomEvent('tool:progress', { - tool: 'testTool', + tool: 'testTool', progress: 75, message: 'Almost done...', }) - + // Emit completion event context?.emitCustomEvent('tool:complete', { tool: 'testTool', result: 'success', duration: 20, }) - + return { processed: args.message } }) @@ -54,7 +54,7 @@ describe('Custom Events Integration', () => { onTextUpdate: vi.fn(), onToolCallStateChange: vi.fn(), onThinkingUpdate: vi.fn(), - } + }, }) // Prepare assistant message @@ -97,39 +97,55 @@ describe('Custom Events Integration', () => { data: { ...data, toolCallId: 'tc-1' }, timestamp: Date.now(), }) - } + }, } - + await toolExecuteFunc({ message: 'Hello World' }, mockContext) } // Verify custom events were emitted expect(onCustomEvent).toHaveBeenCalledTimes(3) - - expect(onCustomEvent).toHaveBeenNthCalledWith(1, 'tool:progress', - { tool: 'testTool', progress: 25, message: 'Starting processing...', toolCallId: 'tc-1' }, - { toolCallId: 'tc-1' } + + expect(onCustomEvent).toHaveBeenNthCalledWith( + 1, + 'tool:progress', + { + tool: 'testTool', + progress: 25, + message: 'Starting processing...', + toolCallId: 'tc-1', + }, + { toolCallId: 'tc-1' }, ) - - expect(onCustomEvent).toHaveBeenNthCalledWith(2, 'tool:progress', - { tool: 'testTool', progress: 75, message: 'Almost done...', toolCallId: 'tc-1' }, - { toolCallId: 'tc-1' } + + expect(onCustomEvent).toHaveBeenNthCalledWith( + 2, + 'tool:progress', + { + tool: 'testTool', + progress: 75, + message: 'Almost done...', + toolCallId: 'tc-1', + }, + { toolCallId: 'tc-1' }, ) - - expect(onCustomEvent).toHaveBeenNthCalledWith(3, 'tool:complete', + + expect(onCustomEvent).toHaveBeenNthCalledWith( + 3, + 'tool:complete', { tool: 'testTool', result: 'success', duration: 20, toolCallId: 'tc-1' }, - { toolCallId: 'tc-1' } + { toolCallId: 'tc-1' }, ) }) it('should handle custom events without toolCallId in context', async () => { const onCustomEvent = vi.fn() - + const processor = new StreamProcessor({ events: { onCustomEvent, onMessagesChange: vi.fn(), - onStreamStart: vi.fn(), + onStreamStart: vi.fn(), onStreamEnd: vi.fn(), onError: vi.fn(), onToolCall: vi.fn(), @@ -137,7 +153,7 @@ describe('Custom Events Integration', () => { onTextUpdate: vi.fn(), onToolCallStateChange: vi.fn(), onThinkingUpdate: vi.fn(), - } + }, }) // Emit custom event without toolCallId @@ -151,7 +167,7 @@ describe('Custom Events Integration', () => { expect(onCustomEvent).toHaveBeenCalledWith( 'system:status', { status: 'ready', version: '1.0.0' }, - { toolCallId: undefined } + { toolCallId: undefined }, ) }) @@ -159,7 +175,7 @@ describe('Custom Events Integration', () => { const onCustomEvent = vi.fn() const onToolCall = vi.fn() const onApprovalRequest = vi.fn() - + const processor = new StreamProcessor({ events: { onCustomEvent, @@ -172,7 +188,7 @@ describe('Custom Events Integration', () => { onTextUpdate: vi.fn(), onToolCallStateChange: vi.fn(), onThinkingUpdate: vi.fn(), - } + }, }) processor.prepareAssistantMessage() @@ -189,7 +205,7 @@ describe('Custom Events Integration', () => { timestamp: Date.now(), }) - // System event: approval-requested + // System event: approval-requested processor.processChunk({ type: 'CUSTOM', name: 'approval-requested', @@ -213,13 +229,13 @@ describe('Custom Events Integration', () => { // System events should trigger their specific handlers, not onCustomEvent expect(onToolCall).toHaveBeenCalledTimes(1) expect(onApprovalRequest).toHaveBeenCalledTimes(1) - + // Only the user custom event should be forwarded expect(onCustomEvent).toHaveBeenCalledTimes(1) expect(onCustomEvent).toHaveBeenCalledWith( 'user:custom-event', { message: 'This should be forwarded' }, - { toolCallId: undefined } + { toolCallId: undefined }, ) }) -}) \ No newline at end of file +}) diff --git a/packages/typescript/ai/tests/stream-processor.test.ts b/packages/typescript/ai/tests/stream-processor.test.ts index 0d6684736..3c2cddf88 100644 --- a/packages/typescript/ai/tests/stream-processor.test.ts +++ b/packages/typescript/ai/tests/stream-processor.test.ts @@ -1189,7 +1189,9 @@ describe('StreamProcessor', () => { processor.processChunk(ev.custom('event-1', { step: 1 })) processor.processChunk(ev.custom('event-2', { step: 2 })) - processor.processChunk(ev.custom('event-3', { step: 3, toolCallId: 'tc-1' })) + processor.processChunk( + ev.custom('event-3', { step: 3, toolCallId: 'tc-1' }), + ) expect(events.onCustomEvent).toHaveBeenCalledTimes(3) expect(events.onCustomEvent).toHaveBeenNthCalledWith( From e73ea9d488ffd530281f58698ca61a0601801795 Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Mon, 16 Feb 2026 19:48:36 -0800 Subject: [PATCH 4/5] adding a changeset --- .changeset/custom-events-dispatch.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/custom-events-dispatch.md diff --git a/.changeset/custom-events-dispatch.md b/.changeset/custom-events-dispatch.md new file mode 100644 index 000000000..aed9b8d58 --- /dev/null +++ b/.changeset/custom-events-dispatch.md @@ -0,0 +1,12 @@ +--- +"@tanstack/ai": minor +"@tanstack/ai-client": minor +"@tanstack/ai-react": patch +"@tanstack/ai-solid": patch +"@tanstack/ai-svelte": patch +"@tanstack/ai-vue": patch +--- + +feat: add custom event dispatch support for tools + +Tools can now emit custom events during execution via `dispatchEvent()`. Custom events are streamed to clients as `custom_event` stream chunks and surfaced through the client chat hook's `onCustomEvent` callback. This enables tools to send progress updates, intermediate results, or any structured data back to the UI during long-running operations. From f49c3cd2e053531e4e1bef36cf1beca27a4db143 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 17 Feb 2026 03:49:48 +0000 Subject: [PATCH 5/5] ci: apply automated fixes --- .changeset/custom-events-dispatch.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.changeset/custom-events-dispatch.md b/.changeset/custom-events-dispatch.md index aed9b8d58..5a124945c 100644 --- a/.changeset/custom-events-dispatch.md +++ b/.changeset/custom-events-dispatch.md @@ -1,10 +1,10 @@ --- -"@tanstack/ai": minor -"@tanstack/ai-client": minor -"@tanstack/ai-react": patch -"@tanstack/ai-solid": patch -"@tanstack/ai-svelte": patch -"@tanstack/ai-vue": patch +'@tanstack/ai': minor +'@tanstack/ai-client': minor +'@tanstack/ai-react': patch +'@tanstack/ai-solid': patch +'@tanstack/ai-svelte': patch +'@tanstack/ai-vue': patch --- feat: add custom event dispatch support for tools