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
11 changes: 11 additions & 0 deletions .changeset/fix-mixed-server-client-tool-stall.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@tanstack/ai': patch
---

Fix chat stall when server and client tools are called in the same turn.

When the LLM requested both a server tool and a client tool in the same response, the server tool's result was silently dropped. The `processToolCalls` and `checkForPendingToolCalls` methods returned early to wait for the client tool, skipping the `emitToolResults` call entirely — so the server result was never emitted or added to the message history, causing the session to stall indefinitely.

The fix emits completed server tool results before yielding the early return for client tool / approval waiting.

Also fixes the smoke-test harness and test fixtures to use `chunk.value` instead of `chunk.data` for CUSTOM events, following the rename introduced in #307.
18 changes: 18 additions & 0 deletions packages/typescript/ai/src/activities/chat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,15 @@ class TextEngine<
executionResult.needsApproval.length > 0 ||
executionResult.needsClientExecution.length > 0
) {
if (executionResult.results.length > 0) {
for (const chunk of this.emitToolResults(
executionResult.results,
finishEvent,
)) {
yield chunk
}
}

for (const chunk of this.emitApprovalRequests(
executionResult.needsApproval,
finishEvent,
Expand Down Expand Up @@ -680,6 +689,15 @@ class TextEngine<
executionResult.needsApproval.length > 0 ||
executionResult.needsClientExecution.length > 0
) {
if (executionResult.results.length > 0) {
for (const chunk of this.emitToolResults(
executionResult.results,
finishEvent,
)) {
yield chunk
}
}

for (const chunk of this.emitApprovalRequests(
executionResult.needsApproval,
finishEvent,
Expand Down
124 changes: 124 additions & 0 deletions packages/typescript/ai/tests/chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,130 @@ describe('chat()', () => {
})
})

// ==========================================================================
// Mixed server + client tools (regression: server results were dropped)
// ==========================================================================
describe('mixed server + client tools', () => {
it('processToolCalls: emits server tool result before waiting for client tool', async () => {
const searchExecute = vi.fn().mockReturnValue({ results: ['a', 'b'] })

const { adapter, calls } = createMockAdapter({
iterations: [
[
ev.runStarted(),
ev.toolStart('call_server', 'searchTools'),
ev.toolArgs('call_server', '{"query":"hello"}'),
ev.toolStart('call_client', 'showNotification'),
ev.toolArgs('call_client', '{"message":"done"}'),
ev.runFinished('tool_calls'),
],
],
})

const stream = chat({
adapter,
messages: [{ role: 'user', content: 'Search and notify' }],
tools: [
serverTool('searchTools', searchExecute),
clientTool('showNotification'),
],
})

const chunks = await collectChunks(stream as AsyncIterable<StreamChunk>)

// 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(
(c) =>
c.type === 'TOOL_CALL_END' &&
(c as any).toolName === 'searchTools' &&
'result' in c &&
(c as any).result,
)
expect(toolEndWithResult).toHaveLength(1)

// Client tool should get a tool-input-available event
const customChunks = chunks.filter(
(c) =>
c.type === 'CUSTOM' && (c as any).name === 'tool-input-available',
)
expect(customChunks).toHaveLength(1)
expect((customChunks[0] as any).value.toolName).toBe('showNotification')

// Adapter called once (waiting for client result, not looping)
expect(calls).toHaveLength(1)
})

it('checkForPendingToolCalls: emits server result before waiting for pending client tool', async () => {
const weatherExecute = vi.fn().mockReturnValue({ temp: 72 })

const { adapter, calls } = createMockAdapter({
iterations: [
// This should NOT be called because we're still waiting for the client tool
],
})

const stream = chat({
adapter,
messages: [
{ role: 'user', content: 'Weather and notify?' },
{
role: 'assistant',
content: '',
toolCalls: [
{
id: 'call_server',
type: 'function' as const,
function: { name: 'getWeather', arguments: '{"city":"NYC"}' },
},
{
id: 'call_client',
type: 'function' as const,
function: {
name: 'showNotification',
arguments: '{"message":"done"}',
},
},
],
},
// No tool result messages -> both are pending
],
tools: [
serverTool('getWeather', weatherExecute),
clientTool('showNotification'),
],
})

const chunks = await collectChunks(stream as AsyncIterable<StreamChunk>)

// 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(
(c) =>
c.type === 'TOOL_CALL_END' &&
(c as any).toolName === 'getWeather' &&
'result' in c &&
(c as any).result,
)
expect(toolEndWithResult).toHaveLength(1)

// Client tool should get a tool-input-available event
const customChunks = chunks.filter(
(c) =>
c.type === 'CUSTOM' && (c as any).name === 'tool-input-available',
)
expect(customChunks).toHaveLength(1)
expect((customChunks[0] as any).value.toolName).toBe('showNotification')

// Adapter should NOT be called (still waiting for client result)
expect(calls).toHaveLength(0)
})
})

// ==========================================================================
// Approval flow
// ==========================================================================
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,7 @@ export interface AdapterDefinition {
}

// Model defaults from environment or sensible defaults
const ANTHROPIC_MODEL =
process.env.ANTHROPIC_MODEL || 'claude-3-5-haiku-20241022'
const ANTHROPIC_MODEL = process.env.ANTHROPIC_MODEL || 'claude-haiku-4-5'
const ANTHROPIC_SUMMARY_MODEL =
process.env.ANTHROPIC_SUMMARY_MODEL || ANTHROPIC_MODEL

Expand Down
14 changes: 7 additions & 7 deletions packages/typescript/smoke-tests/adapters/src/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,21 +289,21 @@ export async function captureStream(opts: {
// AG-UI CUSTOM events (approval requests, tool inputs, etc.)
else if (chunk.type === 'CUSTOM') {
chunkData.name = chunk.name
chunkData.data = chunk.data
chunkData.value = chunk.value

// Handle approval-requested CUSTOM events
if (chunk.name === 'approval-requested' && chunk.data) {
const data = chunk.data as {
if (chunk.name === 'approval-requested' && chunk.value) {
const value = chunk.value as {
toolCallId: string
toolName: string
input: any
approval: any
}
const approval: ApprovalCapture = {
toolCallId: data.toolCallId,
toolName: data.toolName,
input: data.input,
approval: data.approval,
toolCallId: value.toolCallId,
toolName: value.toolName,
input: value.input,
approval: value.approval,
}
approvalRequests.push(approval)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,9 @@ describe('Approval Flow Tests', () => {
expect(approvalChunks.length).toBe(1)

const approvalChunk = approvalChunks[0] as any
expect(approvalChunk.data.toolName).toBe('delete_file')
expect(approvalChunk.data.input).toEqual({ path: '/tmp/important.txt' })
expect(approvalChunk.data.approval.needsApproval).toBe(true)
expect(approvalChunk.value.toolName).toBe('delete_file')
expect(approvalChunk.value.input).toEqual({ path: '/tmp/important.txt' })
expect(approvalChunk.value.approval.needsApproval).toBe(true)

// Tool should NOT be executed yet (waiting for approval)
expect(executeFn).not.toHaveBeenCalled()
Expand Down Expand Up @@ -373,7 +373,7 @@ describe('Approval Flow Tests', () => {
(c: any) => c.type === 'CUSTOM' && c.name === 'approval-requested',
)
expect(approvalChunks.length).toBe(1)
expect((approvalChunks[0] as any).data.toolName).toBe('delete_item')
expect((approvalChunks[0] as any).value.toolName).toBe('delete_item')

// Check tool should have been executed (verify via mock call)
expect(checkExecute).toHaveBeenCalledWith({ id: '123' })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ describe('Client Tool Tests', () => {
expect(inputAvailableChunks.length).toBe(1)

const inputChunk = inputAvailableChunks[0] as any
expect(inputChunk.data.toolName).toBe('show_notification')
expect(inputChunk.data.input).toEqual({
expect(inputChunk.value.toolName).toBe('show_notification')
expect(inputChunk.value.input).toEqual({
message: 'Hello World',
type: 'info',
})
Expand Down Expand Up @@ -276,7 +276,7 @@ describe('Client Tool Tests', () => {

// At least one should be for tool_b
const toolBInputs = inputChunks.filter(
(c: any) => c.data?.toolName === 'client_tool_b',
(c: any) => c.value?.toolName === 'client_tool_b',
)
expect(toolBInputs.length).toBe(1)
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ describe('Tool Sequence Tests', () => {
(c: any) => c.type === 'CUSTOM' && c.name === 'tool-input-available',
)
expect(inputChunks.length).toBe(1)
expect((inputChunks[0] as any).data.toolName).toBe('client_confirm')
expect((inputChunks[0] as any).value.toolName).toBe('client_confirm')
})
})

Expand Down
Loading