Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/custom-events-dispatch.md
Original file line number Diff line number Diff line change
@@ -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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | πŸ”΄ Critical

🧩 Analysis chain

🏁 Script executed:

cat .changeset/custom-events-dispatch.md

Repository: TanStack/ai

Length of output: 626


🏁 Script executed:

# Search for method definitions and usage patterns
rg -n "emitCustomEvent|dispatchEvent" --type ts --type js -A 3 -B 1

Repository: TanStack/ai

Length of output: 5790


🏁 Script executed:

# Search for ToolExecutionContext interface definition
rg -n "interface ToolExecutionContext|class ToolExecutionContext" -A 20 --type ts

Repository: TanStack/ai

Length of output: 1569


🏁 Script executed:

# Look for any tool execution context type definitions
fd -t f -e ts -e d.ts | xargs grep -l "ToolExecutionContext" | head -5

Repository: TanStack/ai

Length of output: 221


Incorrect API name in documentation.

The changeset states tools emit events via dispatchEvent(), but the actual API is context.emitCustomEvent() where context is the ToolExecutionContext passed to the tool's execute function. The implementation, tests, and all usage examples consistently use emitCustomEvent().

Update the documentation to reflect the correct API name:

Suggested fix
-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.
+Tools can now emit custom events during execution via `context.emitCustomEvent()` where `context` is the `ToolExecutionContext` passed to the tool's execute function. 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.
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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.
Tools can now emit custom events during execution via `context.emitCustomEvent()` where `context` is the `ToolExecutionContext` passed to the tool's execute function. 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.
πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.changeset/custom-events-dispatch.md at line 12, The docs incorrectly
reference dispatchEvent(); update the text to use the real API
context.emitCustomEvent() and clarify that emitCustomEvent is called on the
ToolExecutionContext passed into a tool's execute function (e.g.,
context.emitCustomEvent({...})), and ensure mentions of the streaming chunk and
client callback refer to the existing `custom_event` stream chunk and the chat
hook `onCustomEvent` so examples, tests and docs match the implementation.

8 changes: 7 additions & 1 deletion examples/ts-react-chat/src/lib/guitar-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
25 changes: 18 additions & 7 deletions examples/ts-react-chat/src/routes/api.tanchat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
7 changes: 7 additions & 0 deletions examples/ts-react-chat/src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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('')

Expand Down
21 changes: 21 additions & 0 deletions packages/typescript/ai-client/src/chat-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down Expand Up @@ -78,6 +83,7 @@ export class ChatClient {
onLoadingChange: options.onLoadingChange || (() => {}),
onErrorChange: options.onErrorChange || (() => {}),
onStatusChange: options.onStatusChange || (() => {}),
onCustomEvent: options.onCustomEvent || (() => {}),
},
}

Expand Down Expand Up @@ -198,6 +204,13 @@ export class ChatClient {
)
}
},
onCustomEvent: (
eventType: string,
data: unknown,
context: { toolCallId?: string },
) => {
this.callbacksRef.current.onCustomEvent(eventType, data, context)
},
},
})

Expand Down Expand Up @@ -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
Expand All @@ -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
}
}
}
14 changes: 14 additions & 0 deletions packages/typescript/ai-client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
192 changes: 192 additions & 0 deletions packages/typescript/ai-client/tests/chat-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
createTextChunks,
createThinkingChunks,
createToolCallChunks,
createCustomEventChunks,
} from './test-utils'
import type { UIMessage } from '../src/types'

Expand Down Expand Up @@ -901,4 +902,195 @@ 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()
})
})
})
30 changes: 30 additions & 0 deletions packages/typescript/ai-client/tests/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<StreamChunk> {
const chunks: Array<StreamChunk> = []

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
Expand Down
1 change: 1 addition & 0 deletions packages/typescript/ai-react/src/use-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export function useChat<TTools extends ReadonlyArray<AnyClientTool> = any>(
optionsRef.current.onError?.(error)
},
tools: optionsRef.current.tools,
onCustomEvent: optionsRef.current.onCustomEvent,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

onCustomEvent is captured at memo-creation time, unlike onFinish/onError which dereference optionsRef at call time.

onFinish and onError (Lines 60–65) are wrapped in closures that read optionsRef.current when invoked, so they always use the latest callback. onCustomEvent is evaluated once when useMemo runs, meaning subsequent changes to options.onCustomEvent won't take effect until clientId changes.

Suggested fix: wrap in a closure for consistency
-      onCustomEvent: optionsRef.current.onCustomEvent,
+      onCustomEvent: (eventType: string, data: unknown, context: { toolCallId?: string }) => {
+        optionsRef.current.onCustomEvent?.(eventType, data, context)
+      },
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
onCustomEvent: optionsRef.current.onCustomEvent,
onCustomEvent: (eventType: string, data: unknown, context: { toolCallId?: string }) => {
optionsRef.current.onCustomEvent?.(eventType, data, context)
},
πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/typescript/ai-react/src/use-chat.ts` at line 67, onCustomEvent is
being captured at useMemo creation (optionsRef.current.onCustomEvent) so updates
to options.onCustomEvent won't be used until clientId changes; change the
onCustomEvent property in the object created by useMemo to be a closure that
calls optionsRef.current.onCustomEvent(...) at invocation time (matching how
onFinish/onError are implemented) so it always dereferences the latest callback
while leaving useMemo and its clientId dependency intact.

streamProcessor: options.streamProcessor,
onMessagesChange: (newMessages: Array<UIMessage<TTools>>) => {
setMessages(newMessages)
Expand Down
Loading
Loading