diff --git a/.changeset/fix-mixed-server-client-tool-stall.md b/.changeset/fix-mixed-server-client-tool-stall.md new file mode 100644 index 000000000..92563c93c --- /dev/null +++ b/.changeset/fix-mixed-server-client-tool-stall.md @@ -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. diff --git a/packages/typescript/ai/src/activities/chat/index.ts b/packages/typescript/ai/src/activities/chat/index.ts index f7f4b7a66..30a192249 100644 --- a/packages/typescript/ai/src/activities/chat/index.ts +++ b/packages/typescript/ai/src/activities/chat/index.ts @@ -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, @@ -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, diff --git a/packages/typescript/ai/tests/chat.test.ts b/packages/typescript/ai/tests/chat.test.ts index 65bda0067..f024fb22e 100644 --- a/packages/typescript/ai/tests/chat.test.ts +++ b/packages/typescript/ai/tests/chat.test.ts @@ -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) + + // 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) + + // 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 // ========================================================================== diff --git a/packages/typescript/smoke-tests/adapters/src/adapters/index.ts b/packages/typescript/smoke-tests/adapters/src/adapters/index.ts index 25fb6a8a3..31d8d3d61 100644 --- a/packages/typescript/smoke-tests/adapters/src/adapters/index.ts +++ b/packages/typescript/smoke-tests/adapters/src/adapters/index.ts @@ -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 diff --git a/packages/typescript/smoke-tests/adapters/src/harness.ts b/packages/typescript/smoke-tests/adapters/src/harness.ts index c06c8f970..65beb0c44 100644 --- a/packages/typescript/smoke-tests/adapters/src/harness.ts +++ b/packages/typescript/smoke-tests/adapters/src/harness.ts @@ -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) } diff --git a/packages/typescript/smoke-tests/adapters/src/tests/tools/approval.test.ts b/packages/typescript/smoke-tests/adapters/src/tests/tools/approval.test.ts index 2953ad270..8f997aeea 100644 --- a/packages/typescript/smoke-tests/adapters/src/tests/tools/approval.test.ts +++ b/packages/typescript/smoke-tests/adapters/src/tests/tools/approval.test.ts @@ -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() @@ -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' }) diff --git a/packages/typescript/smoke-tests/adapters/src/tests/tools/client-tool.test.ts b/packages/typescript/smoke-tests/adapters/src/tests/tools/client-tool.test.ts index 162f39e4c..5d4bd4f09 100644 --- a/packages/typescript/smoke-tests/adapters/src/tests/tools/client-tool.test.ts +++ b/packages/typescript/smoke-tests/adapters/src/tests/tools/client-tool.test.ts @@ -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', }) @@ -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) }) diff --git a/packages/typescript/smoke-tests/adapters/src/tests/tools/sequences.test.ts b/packages/typescript/smoke-tests/adapters/src/tests/tools/sequences.test.ts index 2b5c98621..ae2be898e 100644 --- a/packages/typescript/smoke-tests/adapters/src/tests/tools/sequences.test.ts +++ b/packages/typescript/smoke-tests/adapters/src/tests/tools/sequences.test.ts @@ -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') }) })