Skip to content

Commit c81fc67

Browse files
committed
fix: preserve thinking order around provider tools
1 parent 5fcaf90 commit c81fc67

6 files changed

Lines changed: 420 additions & 11 deletions

File tree

.changeset/calm-thinkers-wait.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/ai': patch
3+
---
4+
5+
Preserve signed thinking blocks relative to tool calls when converting assistant UI messages. A thinking block that follows a provider-executed tool now starts a new assistant segment instead of being replayed before that tool.

packages/ai/src/activities/chat/messages.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isProviderExecutedToolCall } from '../../utilities/provider-executed'
12
import { normalizeToolResult } from '../../utilities/tool-result'
23
import type { Message as AGUIMessage } from '@ag-ui/core'
34
import type {
@@ -297,6 +298,12 @@ function buildAssistantMessages(uiMessage: UIMessage): Array<ModelMessage> {
297298

298299
case 'thinking':
299300
if (part.content) {
301+
// A thinking block after a tool call belongs to the next assistant
302+
// segment. Provider-executed tools have no separate tool-result part
303+
// to create this boundary for us.
304+
if (current.toolCalls.some(isProviderExecutedToolCall)) {
305+
flushSegment()
306+
}
300307
pendingThinking.push({
301308
content: part.content,
302309
...(part.signature && { signature: part.signature }),

packages/ai/tests/message-converters.test.ts

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,208 @@ describe('Message Converters', () => {
201201
expect(result[0]?.thinking).toEqual([{ content: 'Let me think...' }])
202202
})
203203

204+
it('should preserve thinking order around provider-executed tool calls', () => {
205+
const providerToolMetadata = {
206+
providerExecuted: true,
207+
anthropic: {
208+
serverToolType: 'web_search',
209+
resultBlockType: 'web_search_tool_result',
210+
result: [
211+
{
212+
type: 'web_search_result',
213+
title: 'Example result',
214+
url: 'https://example.com',
215+
encrypted_content: 'opaque-provider-payload',
216+
},
217+
],
218+
},
219+
}
220+
const uiMessage: UIMessage = {
221+
id: 'assistant-message',
222+
role: 'assistant',
223+
parts: [
224+
{
225+
type: 'thinking',
226+
content: 'First signed thinking block',
227+
signature: 'signature-1',
228+
},
229+
{
230+
type: 'tool-call',
231+
id: 'srvtoolu_search',
232+
name: 'web_search',
233+
arguments: '{"query":"top AI companies"}',
234+
state: 'input-complete',
235+
metadata: providerToolMetadata,
236+
},
237+
{
238+
type: 'thinking',
239+
content: 'Second signed thinking block',
240+
signature: 'signature-2',
241+
},
242+
{
243+
type: 'tool-call',
244+
id: 'toolu_create_block',
245+
name: 'createBlock',
246+
arguments: '{"block":{"type":"entity-grid"}}',
247+
state: 'complete',
248+
output: { ok: true },
249+
},
250+
{
251+
type: 'tool-result',
252+
toolCallId: 'toolu_create_block',
253+
content: '{"ok":true}',
254+
state: 'complete',
255+
},
256+
],
257+
}
258+
259+
const result = convertMessagesToModelMessages([uiMessage])
260+
261+
expect(result).toEqual([
262+
{
263+
role: 'assistant',
264+
content: null,
265+
toolCalls: [
266+
{
267+
id: 'srvtoolu_search',
268+
type: 'function',
269+
function: {
270+
name: 'web_search',
271+
arguments: '{"query":"top AI companies"}',
272+
},
273+
metadata: providerToolMetadata,
274+
},
275+
],
276+
thinking: [
277+
{
278+
content: 'First signed thinking block',
279+
signature: 'signature-1',
280+
},
281+
],
282+
},
283+
{
284+
role: 'assistant',
285+
content: null,
286+
toolCalls: [
287+
{
288+
id: 'toolu_create_block',
289+
type: 'function',
290+
function: {
291+
name: 'createBlock',
292+
arguments: '{"block":{"type":"entity-grid"}}',
293+
},
294+
},
295+
],
296+
thinking: [
297+
{
298+
content: 'Second signed thinking block',
299+
signature: 'signature-2',
300+
},
301+
],
302+
},
303+
{
304+
role: 'tool',
305+
content: '{"ok":true}',
306+
toolCallId: 'toolu_create_block',
307+
},
308+
])
309+
})
310+
311+
it('should keep local tool calls and results in the same assistant turn', () => {
312+
const uiMessage: UIMessage = {
313+
id: 'assistant-message',
314+
role: 'assistant',
315+
parts: [
316+
{
317+
type: 'tool-call',
318+
id: 'tool-call-a',
319+
name: 'toolA',
320+
arguments: '{"value":"a"}',
321+
state: 'input-complete',
322+
},
323+
{
324+
type: 'thinking',
325+
content: 'Thinking between local tool calls',
326+
},
327+
{
328+
type: 'tool-call',
329+
id: 'tool-call-b',
330+
name: 'toolB',
331+
arguments: '{"value":"b"}',
332+
state: 'input-complete',
333+
},
334+
{
335+
type: 'tool-result',
336+
toolCallId: 'tool-call-a',
337+
content: '{"result":"a"}',
338+
state: 'complete',
339+
},
340+
{
341+
type: 'tool-result',
342+
toolCallId: 'tool-call-b',
343+
content: '{"result":"b"}',
344+
state: 'complete',
345+
},
346+
],
347+
}
348+
349+
const modelMessages = uiMessageToModelMessages(uiMessage)
350+
351+
expect(modelMessages).toEqual([
352+
{
353+
role: 'assistant',
354+
content: null,
355+
toolCalls: [
356+
{
357+
id: 'tool-call-a',
358+
type: 'function',
359+
function: {
360+
name: 'toolA',
361+
arguments: '{"value":"a"}',
362+
},
363+
},
364+
{
365+
id: 'tool-call-b',
366+
type: 'function',
367+
function: {
368+
name: 'toolB',
369+
arguments: '{"value":"b"}',
370+
},
371+
},
372+
],
373+
thinking: [{ content: 'Thinking between local tool calls' }],
374+
},
375+
{
376+
role: 'tool',
377+
content: '{"result":"a"}',
378+
toolCallId: 'tool-call-a',
379+
},
380+
{
381+
role: 'tool',
382+
content: '{"result":"b"}',
383+
toolCallId: 'tool-call-b',
384+
},
385+
])
386+
387+
const roundTripped = modelMessagesToUIMessages(modelMessages)
388+
389+
expect(roundTripped).toHaveLength(1)
390+
expect(roundTripped[0]?.parts).toEqual(
391+
expect.arrayContaining([
392+
expect.objectContaining({ type: 'tool-call', id: 'tool-call-a' }),
393+
expect.objectContaining({ type: 'tool-call', id: 'tool-call-b' }),
394+
expect.objectContaining({
395+
type: 'tool-result',
396+
toolCallId: 'tool-call-a',
397+
}),
398+
expect.objectContaining({
399+
type: 'tool-result',
400+
toolCallId: 'tool-call-b',
401+
}),
402+
]),
403+
)
404+
})
405+
204406
it('should skip system messages', () => {
205407
const uiMessage: UIMessage = {
206408
id: 'msg-1',

testing/e2e/global-setup.ts

Lines changed: 86 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -504,11 +504,9 @@ function geminiOmniVideoMount(): Mountable {
504504
}
505505

506506
/**
507-
* Mounts a Claude-shaped SSE response that includes a client `tool_use` block
508-
* followed by a `web_fetch` `server_tool_use` block, plus its
509-
* `web_fetch_tool_result`. Reproduces the streaming scenario from issue #604
510-
* — the adapter must not let server-tool `input_json_delta`s leak into the
511-
* prior client tool's input buffer.
507+
* Mounts Claude-shaped responses for server-tool regression coverage. The
508+
* streaming response reproduces issue #604, while the request validator
509+
* reproduces Anthropic's signed-thinking ordering check from issue #910.
512510
*
513511
* The first turn returns the mixed tool_use + server_tool_use response so the
514512
* adapter can dispatch the client tool. The second turn (after the client
@@ -529,15 +527,25 @@ function anthropicServerToolBugMount(): Mountable {
529527
}
530528

531529
const bodyText = await readBody(req)
530+
type RequestBlock = {
531+
type: string
532+
text?: string
533+
thinking?: string
534+
signature?: string
535+
}
536+
type RequestMessage = {
537+
role: string
538+
content?: Array<RequestBlock> | string
539+
}
540+
532541
let hasToolResult = false
542+
let messages: Array<RequestMessage> = []
533543
try {
534544
const body = JSON.parse(bodyText) as {
535-
messages?: Array<{
536-
role: string
537-
content?: Array<{ type: string }> | string
538-
}>
545+
messages?: Array<RequestMessage>
539546
}
540-
hasToolResult = (body.messages ?? []).some(
547+
messages = body.messages ?? []
548+
hasToolResult = messages.some(
541549
(m) =>
542550
Array.isArray(m.content) &&
543551
m.content.some((c) => c.type === 'tool_result'),
@@ -546,6 +554,74 @@ function anthropicServerToolBugMount(): Mountable {
546554
// Malformed body — fall through and emit the first-turn stream.
547555
}
548556

557+
const thinkingOrderMarker = '[thinking-tool-order] continue'
558+
const hasThinkingOrderMarker = messages.some((message) => {
559+
if (message.role !== 'user') return false
560+
if (typeof message.content === 'string') {
561+
return message.content === thinkingOrderMarker
562+
}
563+
return message.content?.some(
564+
(block) =>
565+
block.type === 'text' && block.text === thinkingOrderMarker,
566+
)
567+
})
568+
const hasThinkingOrderSignature = messages.some(
569+
(message) =>
570+
message.role === 'assistant' &&
571+
Array.isArray(message.content) &&
572+
message.content.some(
573+
(block) =>
574+
block.type === 'thinking' &&
575+
(block.signature === 'signature-1' ||
576+
block.signature === 'signature-2'),
577+
),
578+
)
579+
const validatesThinkingOrder =
580+
hasThinkingOrderMarker || hasThinkingOrderSignature
581+
582+
if (validatesThinkingOrder) {
583+
const assistantMessage = messages.find(
584+
(message) =>
585+
message.role === 'assistant' && Array.isArray(message.content),
586+
)
587+
const blocks = Array.isArray(assistantMessage?.content)
588+
? assistantMessage.content
589+
: []
590+
const blockTypes = blocks.map((block) => block.type)
591+
const expectedBlockTypes = [
592+
'thinking',
593+
'server_tool_use',
594+
'web_search_tool_result',
595+
'thinking',
596+
'tool_use',
597+
]
598+
const thinkingBlocks = blocks.filter(
599+
(block) => block.type === 'thinking',
600+
)
601+
const preservesOrder =
602+
JSON.stringify(blockTypes) === JSON.stringify(expectedBlockTypes) &&
603+
thinkingBlocks[0]?.thinking === 'First signed thinking block' &&
604+
thinkingBlocks[0]?.signature === 'signature-1' &&
605+
thinkingBlocks[1]?.thinking === 'Second signed thinking block' &&
606+
thinkingBlocks[1]?.signature === 'signature-2'
607+
608+
if (!preservesOrder) {
609+
res.statusCode = 400
610+
res.setHeader('Content-Type', 'application/json')
611+
res.end(
612+
JSON.stringify({
613+
type: 'error',
614+
error: {
615+
type: 'invalid_request_error',
616+
message:
617+
'Signed thinking blocks were reordered relative to tool-use blocks',
618+
},
619+
}),
620+
)
621+
return true
622+
}
623+
}
624+
549625
const events = hasToolResult
550626
? buildFollowUpEvents()
551627
: buildToolPlusServerToolEvents()

0 commit comments

Comments
 (0)