Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
9 changes: 8 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*)"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
]
}
}
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
Loading
Loading