Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/chat-no-duplicate-tool-call-end.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai': patch
---

Fix duplicate `TOOL_CALL_END` for server-executed tools. The adapter already streams `START`/`ARGS`/`END` for each tool call, but `chat()` emitted a second `END` afterwards with no matching `START` — an orphan event that AG-UI-strict consumers (e.g. `@ag-ui/client`'s `verifyEvents`) reject. The post-execution phase now only adds `TOOL_CALL_RESULT`. Fixes #519.
5 changes: 5 additions & 0 deletions .changeset/devtools-tool-result-from-result-event.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-event-client': patch
---

Surface server-executed tool results in devtools from the `TOOL_CALL_RESULT` event. The devtools middleware previously read results only off `TOOL_CALL_END`, which the adapter emits before the tool runs (so it carries no result). Now that `chat()` no longer re-emits a post-execution `TOOL_CALL_END` (see the `@tanstack/ai` #519 fix), results travel on the spec-compliant `TOOL_CALL_RESULT` event — the middleware now handles it so devtools keeps showing server-tool output.
22 changes: 22 additions & 0 deletions packages/ai-event-client/src/devtools-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ interface DevtoolsToolCallEndChunk {
toolCallId: string
result?: string
}
interface DevtoolsToolCallResultChunk {
type: 'TOOL_CALL_RESULT'
toolCallId: string
content?: string
}
interface DevtoolsRunFinishedChunk {
type: 'RUN_FINISHED'
finishReason?: 'stop' | 'length' | 'content_filter' | 'tool_calls' | null
Expand All @@ -60,6 +65,7 @@ type DevtoolsKnownChunk =
| DevtoolsToolCallStartChunk
| DevtoolsToolCallArgsChunk
| DevtoolsToolCallEndChunk
| DevtoolsToolCallResultChunk
| DevtoolsRunFinishedChunk
| DevtoolsRunErrorChunk
| DevtoolsStepFinishedChunk
Expand All @@ -75,6 +81,7 @@ const KNOWN_CHUNK_TYPES: ReadonlySet<DevtoolsKnownChunk['type']> = new Set([
'TOOL_CALL_START',
'TOOL_CALL_ARGS',
'TOOL_CALL_END',
'TOOL_CALL_RESULT',
'RUN_FINISHED',
'RUN_ERROR',
'STEP_FINISHED',
Expand Down Expand Up @@ -385,6 +392,21 @@ export function devtoolsMiddleware(): DevtoolsChatMiddleware {
})
break
}
case 'TOOL_CALL_RESULT': {
// Server-executed tool results arrive on the spec-compliant
// TOOL_CALL_RESULT event (the adapter's TOOL_CALL_END carries only
// the parsed input). Surface them to devtools from here so results
// still show up now that the post-execution END is no longer
// re-emitted (#519).
safeEmit('text:chunk:tool-result', {
...base,
messageId: localMessageId || undefined,
toolCallId: chunk.toolCallId,
result: chunk.content || '',
timestamp: Date.now(),
})
break
}
case 'RUN_FINISHED': {
safeEmit('text:chunk:done', {
...base,
Expand Down
27 changes: 14 additions & 13 deletions packages/ai/src/activities/chat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1639,8 +1639,9 @@ class TextEngine<
const wireContent =
typeof content === 'string' ? content : JSON.stringify(content)

// Emit TOOL_CALL_START + TOOL_CALL_ARGS before TOOL_CALL_END so that
// the client can reconstruct the full tool call during continuations.
// argsMap is set only on continuation re-executions, where the adapter
// never streamed these calls. Otherwise it already emitted END, so a
// second one here would be an orphan that fails verifyEvents (#519).
if (argsMap) {
chunks.push({
type: 'TOOL_CALL_START',
Expand All @@ -1660,18 +1661,18 @@ class TextEngine<
delta: args,
args,
} as StreamChunk)
}

chunks.push({
type: 'TOOL_CALL_END',
timestamp: Date.now(),
model: finishEvent.model,
toolCallId: result.toolCallId,
toolCallName: result.toolName,
toolName: result.toolName,
result: wireContent,
...(result.state !== undefined && { state: result.state }),
} as StreamChunk)
chunks.push({
type: 'TOOL_CALL_END',
timestamp: Date.now(),
model: finishEvent.model,
toolCallId: result.toolCallId,
toolCallName: result.toolName,
toolName: result.toolName,
result: wireContent,
...(result.state !== undefined && { state: result.state }),
} as StreamChunk)
}

// AG-UI spec TOOL_CALL_RESULT event (content is string-only per spec)
chunks.push({
Expand Down
72 changes: 70 additions & 2 deletions packages/ai/tests/chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ describe('chat()', () => {
ev.runStarted(),
ev.toolStart('call_1', 'failTool'),
ev.toolArgs('call_1', '{}'),
ev.toolEnd('call_1', 'failTool', { input: {} }),
ev.runFinished('tool_calls'),
],
[
Expand Down Expand Up @@ -286,10 +287,71 @@ describe('chat()', () => {
const contentStr = (toolResultChunks[0] as any).content
expect(contentStr).toContain('error')

const toolCallEnd = chunks.find(
// Error state rides on TOOL_CALL_RESULT, not the END
const toolResultErr = chunks.find(
(c) => c.type === 'TOOL_CALL_RESULT' && c.toolCallId === 'call_1',
)
expect(toolResultErr).toMatchObject({ state: 'output-error' })

// No duplicate END (#519)
const endChunks = chunks.filter(
(c) => c.type === 'TOOL_CALL_END' && c.toolCallId === 'call_1',
)
expect(toolCallEnd).toMatchObject({ state: 'output-error' })
expect(endChunks).toHaveLength(1)
})

// #519: post-execution must not duplicate the END the adapter already streamed
it('should emit exactly one TOOL_CALL_END per server-executed tool', async () => {
const { adapter } = createMockAdapter({
iterations: [
[
ev.runStarted(),
ev.toolStart('call_1', 'getWeather'),
ev.toolArgs('call_1', '{"city":"NYC"}'),
ev.toolEnd('call_1', 'getWeather', { input: { city: 'NYC' } }),
ev.runFinished('tool_calls'),
],
[
ev.runStarted(),
ev.textStart(),
ev.textContent('72F in NYC.'),
ev.textEnd(),
ev.runFinished('stop'),
],
],
})

const stream = chat({
adapter,
messages: [{ role: 'user', content: 'Weather?' }],
tools: [serverTool('getWeather', () => ({ temp: 72 }))],
})

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

const starts = chunks.filter(
(c) => c.type === 'TOOL_CALL_START' && c.toolCallId === 'call_1',
)
const ends = chunks.filter(
(c) => c.type === 'TOOL_CALL_END' && c.toolCallId === 'call_1',
)
const results = chunks.filter(
(c) => c.type === 'TOOL_CALL_RESULT' && c.toolCallId === 'call_1',
)

// pre-fix `ends` was 2
expect(starts).toHaveLength(1)
expect(ends).toHaveLength(1)
expect(results).toHaveLength(1)

// Every END has a matching START (the verifyEvents invariant)
const open = new Set<string>()
for (const c of chunks) {
if (c.type === 'TOOL_CALL_START') open.add(c.toolCallId)
if (c.type === 'TOOL_CALL_END') {
expect(open.has(c.toolCallId)).toBe(true)
}
}
})
})

Expand Down Expand Up @@ -1528,6 +1590,9 @@ describe('chat()', () => {
'call_disc',
JSON.stringify({ toolNames: ['getWeather'] }),
)
yield ev.toolEnd('call_disc', '__lazy__tool__discovery__', {
input: { toolNames: ['getWeather'] },
})
yield ev.runFinished('tool_calls')
})()
} else if (callCount === 2 && toolNames.includes('getWeather')) {
Expand All @@ -1536,6 +1601,9 @@ describe('chat()', () => {
yield ev.runStarted()
yield ev.toolStart('call_weather', 'getWeather')
yield ev.toolArgs('call_weather', '{"city":"NYC"}')
yield ev.toolEnd('call_weather', 'getWeather', {
input: { city: 'NYC' },
})
yield ev.runFinished('tool_calls')
})()
} else {
Expand Down
3 changes: 1 addition & 2 deletions packages/ai/tests/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1449,8 +1449,7 @@ describe('chat() middleware', () => {
// Tool execution phase
'onBeforeToolCall:myTool',
'onAfterToolCall:myTool:true',
// Tool result events (piped through middleware)
'onChunk:TOOL_CALL_END',
// Only the result — the adapter already streamed END above (#519)
'onChunk:TOOL_CALL_RESULT',
// Second model call (beforeModel phase)
'onConfig:beforeModel',
Expand Down
22 changes: 22 additions & 0 deletions testing/e2e/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { Route as ApiVideoRouteImport } from './routes/api.video'
import { Route as ApiTtsRouteImport } from './routes/api.tts'
import { Route as ApiTranscriptionRouteImport } from './routes/api.transcription'
import { Route as ApiToolsTestRouteImport } from './routes/api.tools-test'
import { Route as ApiToolCallLifecycleWireRouteImport } from './routes/api.tool-call-lifecycle-wire'
import { Route as ApiSummarizeRouteImport } from './routes/api.summarize'
import { Route as ApiOpenrouterWebToolsWireRouteImport } from './routes/api.openrouter-web-tools-wire'
import { Route as ApiOpenrouterCostRouteImport } from './routes/api.openrouter-cost'
Expand Down Expand Up @@ -117,6 +118,12 @@ const ApiToolsTestRoute = ApiToolsTestRouteImport.update({
path: '/api/tools-test',
getParentRoute: () => rootRouteImport,
} as any)
const ApiToolCallLifecycleWireRoute =
ApiToolCallLifecycleWireRouteImport.update({
id: '/api/tool-call-lifecycle-wire',
path: '/api/tool-call-lifecycle-wire',
getParentRoute: () => rootRouteImport,
} as any)
const ApiSummarizeRoute = ApiSummarizeRouteImport.update({
id: '/api/summarize',
path: '/api/summarize',
Expand Down Expand Up @@ -228,6 +235,7 @@ export interface FileRoutesByFullPath {
'/api/openrouter-cost': typeof ApiOpenrouterCostRoute
'/api/openrouter-web-tools-wire': typeof ApiOpenrouterWebToolsWireRoute
'/api/summarize': typeof ApiSummarizeRoute
'/api/tool-call-lifecycle-wire': typeof ApiToolCallLifecycleWireRoute
'/api/tools-test': typeof ApiToolsTestRoute
'/api/transcription': typeof ApiTranscriptionRouteWithChildren
'/api/tts': typeof ApiTtsRouteWithChildren
Expand Down Expand Up @@ -262,6 +270,7 @@ export interface FileRoutesByTo {
'/api/openrouter-cost': typeof ApiOpenrouterCostRoute
'/api/openrouter-web-tools-wire': typeof ApiOpenrouterWebToolsWireRoute
'/api/summarize': typeof ApiSummarizeRoute
'/api/tool-call-lifecycle-wire': typeof ApiToolCallLifecycleWireRoute
'/api/tools-test': typeof ApiToolsTestRoute
'/api/transcription': typeof ApiTranscriptionRouteWithChildren
'/api/tts': typeof ApiTtsRouteWithChildren
Expand Down Expand Up @@ -297,6 +306,7 @@ export interface FileRoutesById {
'/api/openrouter-cost': typeof ApiOpenrouterCostRoute
'/api/openrouter-web-tools-wire': typeof ApiOpenrouterWebToolsWireRoute
'/api/summarize': typeof ApiSummarizeRoute
'/api/tool-call-lifecycle-wire': typeof ApiToolCallLifecycleWireRoute
'/api/tools-test': typeof ApiToolsTestRoute
'/api/transcription': typeof ApiTranscriptionRouteWithChildren
'/api/tts': typeof ApiTtsRouteWithChildren
Expand Down Expand Up @@ -333,6 +343,7 @@ export interface FileRouteTypes {
| '/api/openrouter-cost'
| '/api/openrouter-web-tools-wire'
| '/api/summarize'
| '/api/tool-call-lifecycle-wire'
| '/api/tools-test'
| '/api/transcription'
| '/api/tts'
Expand Down Expand Up @@ -367,6 +378,7 @@ export interface FileRouteTypes {
| '/api/openrouter-cost'
| '/api/openrouter-web-tools-wire'
| '/api/summarize'
| '/api/tool-call-lifecycle-wire'
| '/api/tools-test'
| '/api/transcription'
| '/api/tts'
Expand Down Expand Up @@ -401,6 +413,7 @@ export interface FileRouteTypes {
| '/api/openrouter-cost'
| '/api/openrouter-web-tools-wire'
| '/api/summarize'
| '/api/tool-call-lifecycle-wire'
| '/api/tools-test'
| '/api/transcription'
| '/api/tts'
Expand Down Expand Up @@ -436,6 +449,7 @@ export interface RootRouteChildren {
ApiOpenrouterCostRoute: typeof ApiOpenrouterCostRoute
ApiOpenrouterWebToolsWireRoute: typeof ApiOpenrouterWebToolsWireRoute
ApiSummarizeRoute: typeof ApiSummarizeRoute
ApiToolCallLifecycleWireRoute: typeof ApiToolCallLifecycleWireRoute
ApiToolsTestRoute: typeof ApiToolsTestRoute
ApiTranscriptionRoute: typeof ApiTranscriptionRouteWithChildren
ApiTtsRoute: typeof ApiTtsRouteWithChildren
Expand Down Expand Up @@ -550,6 +564,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiToolsTestRouteImport
parentRoute: typeof rootRouteImport
}
'/api/tool-call-lifecycle-wire': {
id: '/api/tool-call-lifecycle-wire'
path: '/api/tool-call-lifecycle-wire'
fullPath: '/api/tool-call-lifecycle-wire'
preLoaderRoute: typeof ApiToolCallLifecycleWireRouteImport
parentRoute: typeof rootRouteImport
}
'/api/summarize': {
id: '/api/summarize'
path: '/api/summarize'
Expand Down Expand Up @@ -753,6 +774,7 @@ const rootRouteChildren: RootRouteChildren = {
ApiOpenrouterCostRoute: ApiOpenrouterCostRoute,
ApiOpenrouterWebToolsWireRoute: ApiOpenrouterWebToolsWireRoute,
ApiSummarizeRoute: ApiSummarizeRoute,
ApiToolCallLifecycleWireRoute: ApiToolCallLifecycleWireRoute,
ApiToolsTestRoute: ApiToolsTestRoute,
ApiTranscriptionRoute: ApiTranscriptionRouteWithChildren,
ApiTtsRoute: ApiTtsRouteWithChildren,
Expand Down
Loading