Skip to content

Commit 5d98472

Browse files
fix: anthropic tool call issues (#275)
* fix: anthropic tool call issues * fixing pnpm lock * ci: apply automated fixes * reworking model to uimessage conversions * simplifying the message conversion handling * ci: apply automated fixes * more small fixups * simplifying the message conversion handling * small test fixups * ci: apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent c10cd97 commit 5d98472

31 files changed

Lines changed: 6868 additions & 725 deletions
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
'@tanstack/ai': patch
3+
'@tanstack/ai-client': patch
4+
'@tanstack/ai-anthropic': patch
5+
'@tanstack/ai-gemini': patch
6+
---
7+
8+
fix(ai, ai-client, ai-anthropic, ai-gemini): fix multi-turn conversations failing after tool calls
9+
10+
**Core (@tanstack/ai):**
11+
12+
- Lazy assistant message creation: `StreamProcessor` now defers creating the assistant message until the first content-bearing chunk arrives (text, tool call, thinking, or error), eliminating empty `parts: []` messages from appearing during auto-continuation when the model returns no content
13+
- Add `prepareAssistantMessage()` (lazy) alongside deprecated `startAssistantMessage()` (eager, backwards-compatible)
14+
- Add `getCurrentAssistantMessageId()` to check if a message was created
15+
- **Rewrite `uiMessageToModelMessages()` to preserve part ordering**: the function now walks parts sequentially instead of separating by type, producing correctly interleaved assistant/tool messages (text1 + toolCall1 → toolResult1 → text2 + toolCall2 → toolResult2) instead of concatenating all text and batching all tool calls. This fixes multi-round tool flows where the model would see garbled conversation history and re-call tools unnecessarily.
16+
- Deduplicate tool result messages: when a client tool has both a `tool-result` part and a `tool-call` part with `output`, only one `role: 'tool'` message is emitted per tool call ID
17+
18+
**Client (@tanstack/ai-client):**
19+
20+
- Update `ChatClient.processStream()` to use lazy assistant message creation, preventing UI flicker from empty messages being created then removed
21+
22+
**Anthropic:**
23+
24+
- Fix consecutive user-role messages violating Anthropic's alternating role requirement by merging them in `formatMessages`
25+
- Deduplicate `tool_result` blocks with the same `tool_use_id`
26+
- Filter out empty assistant messages from conversation history
27+
- Suppress duplicate `RUN_FINISHED` event from `message_stop` when `message_delta` already emitted one
28+
- Fix `TEXT_MESSAGE_END` incorrectly emitting for `tool_use` content blocks
29+
- Add Claude Opus 4.6 model support with adaptive thinking and effort parameter
30+
31+
**Gemini:**
32+
33+
- Fix consecutive user-role messages violating Gemini's alternating role requirement by merging them in `formatMessages`
34+
- Deduplicate `functionResponse` parts with the same name (tool call ID)
35+
- Filter out empty model messages from conversation history
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@tanstack/ai': minor
3+
'@tanstack/ai-anthropic': minor
4+
'@tanstack/ai-gemini': minor
5+
---
6+
7+
Tighten the AG-UI adapter contract and simplify the core stream processor.
8+
9+
**Breaking type changes:**
10+
11+
- `TextMessageContentEvent.delta` is now required (was optional)
12+
- `StepFinishedEvent.delta` is now required (was optional)
13+
14+
All first-party adapters already sent `delta` on every event, so this is a type-level enforcement of existing behavior. Community adapters that follow the reference implementations will not need code changes.
15+
16+
**Core processor simplifications:**
17+
18+
- `TEXT_MESSAGE_START` now resets text segment state, replacing heuristic overlap detection
19+
- `TOOL_CALL_END` is now the authoritative signal for tool call input completion
20+
- Removed delta/content fallback logic, whitespace-only message cleanup, and finish-reason conflict arbitration from the processor
21+
22+
**Adapter fixes:**
23+
24+
- Gemini: filter whitespace-only text parts, fix STEP_FINISHED content accumulation, emit fresh TEXT_MESSAGE_START after tool calls
25+
- Anthropic: emit fresh TEXT_MESSAGE_START after tool_use blocks for proper text segmentation

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,6 @@ test-traces
5656

5757
# Playwright
5858
playwright-report
59-
test-results
59+
test-results
60+
61+
STATUS_*.md

examples/ts-group-chat/package.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@
1414
"@tanstack/ai-client": "workspace:*",
1515
"@tanstack/ai-react": "workspace:*",
1616
"@tanstack/react-devtools": "^0.8.2",
17-
"@tanstack/react-router": "^1.141.1",
18-
"@tanstack/react-router-devtools": "^1.139.7",
19-
"@tanstack/react-router-ssr-query": "^1.139.7",
20-
"@tanstack/react-start": "^1.141.1",
21-
"@tanstack/router-plugin": "^1.139.7",
17+
"@tanstack/react-router": "^1.158.4",
18+
"@tanstack/react-router-devtools": "^1.158.4",
19+
"@tanstack/react-router-ssr-query": "^1.158.4",
20+
"@tanstack/react-start": "^1.159.0",
21+
"@tanstack/router-plugin": "^1.158.4",
2222
"capnweb": "^0.1.0",
2323
"react": "^19.2.3",
2424
"react-dom": "^19.2.3",

examples/ts-react-chat/package.json

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,14 @@
2020
"@tanstack/ai-openrouter": "workspace:*",
2121
"@tanstack/ai-react": "workspace:*",
2222
"@tanstack/ai-react-ui": "workspace:*",
23-
"@tanstack/nitro-v2-vite-plugin": "^1.141.0",
23+
"@tanstack/nitro-v2-vite-plugin": "^1.154.7",
2424
"@tanstack/react-devtools": "^0.8.2",
25-
"@tanstack/react-router": "^1.141.1",
26-
"@tanstack/react-router-devtools": "^1.139.7",
27-
"@tanstack/react-router-ssr-query": "^1.139.7",
28-
"@tanstack/react-start": "^1.141.1",
25+
"@tanstack/react-router": "^1.158.4",
26+
"@tanstack/react-router-devtools": "^1.158.4",
27+
"@tanstack/react-router-ssr-query": "^1.158.4",
28+
"@tanstack/react-start": "^1.159.0",
2929
"@tanstack/react-store": "^0.8.0",
30-
"@tanstack/router-plugin": "^1.139.7",
30+
"@tanstack/router-plugin": "^1.158.4",
3131
"@tanstack/store": "^0.8.0",
3232
"highlight.js": "^11.11.1",
3333
"lucide-react": "^0.561.0",

examples/ts-react-chat/src/routeTree.gen.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export interface FileRoutesByFullPath {
3939
'/': typeof IndexRoute
4040
'/api/tanchat': typeof ApiTanchatRoute
4141
'/example/guitars/$guitarId': typeof ExampleGuitarsGuitarIdRoute
42-
'/example/guitars': typeof ExampleGuitarsIndexRoute
42+
'/example/guitars/': typeof ExampleGuitarsIndexRoute
4343
}
4444
export interface FileRoutesByTo {
4545
'/': typeof IndexRoute
@@ -60,7 +60,7 @@ export interface FileRouteTypes {
6060
| '/'
6161
| '/api/tanchat'
6262
| '/example/guitars/$guitarId'
63-
| '/example/guitars'
63+
| '/example/guitars/'
6464
fileRoutesByTo: FileRoutesByTo
6565
to: '/' | '/api/tanchat' | '/example/guitars/$guitarId' | '/example/guitars'
6666
id:
@@ -97,7 +97,7 @@ declare module '@tanstack/react-router' {
9797
'/example/guitars/': {
9898
id: '/example/guitars/'
9999
path: '/example/guitars'
100-
fullPath: '/example/guitars'
100+
fullPath: '/example/guitars/'
101101
preLoaderRoute: typeof ExampleGuitarsIndexRouteImport
102102
parentRoute: typeof rootRouteImport
103103
}

examples/ts-solid-chat/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@
1919
"@tanstack/ai-openai": "workspace:*",
2020
"@tanstack/ai-solid": "workspace:*",
2121
"@tanstack/ai-solid-ui": "workspace:*",
22-
"@tanstack/nitro-v2-vite-plugin": "^1.141.0",
23-
"@tanstack/router-plugin": "^1.139.7",
22+
"@tanstack/nitro-v2-vite-plugin": "^1.154.7",
23+
"@tanstack/router-plugin": "^1.158.4",
2424
"@tanstack/solid-ai-devtools": "workspace:*",
2525
"@tanstack/solid-devtools": "^0.7.15",
2626
"@tanstack/solid-router": "^1.139.10",

packages/typescript/ai-anthropic/src/adapters/text.ts

Lines changed: 121 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,7 @@ export class AnthropicTextAdapter<
247247
const validKeys: Array<keyof InternalTextProviderOptions> = [
248248
'container',
249249
'context_management',
250+
'effort',
250251
'mcp_servers',
251252
'service_tier',
252253
'stop_sequences',
@@ -450,7 +451,74 @@ export class AnthropicTextAdapter<
450451
})
451452
}
452453

453-
return formattedMessages
454+
// Post-process: Anthropic requires strictly alternating user/assistant roles.
455+
// Tool results are sent as role:'user' messages, which can create consecutive
456+
// user messages when followed by a new user message. Merge them.
457+
return this.mergeConsecutiveSameRoleMessages(formattedMessages)
458+
}
459+
460+
/**
461+
* Merge consecutive messages of the same role into a single message.
462+
* Anthropic's API requires strictly alternating user/assistant roles.
463+
* Tool results are wrapped as role:'user' messages, which can collide
464+
* with actual user messages in multi-turn conversations.
465+
*
466+
* Also filters out empty assistant messages (e.g., from a previous failed request).
467+
*/
468+
private mergeConsecutiveSameRoleMessages(
469+
messages: InternalTextProviderOptions['messages'],
470+
): InternalTextProviderOptions['messages'] {
471+
const merged: InternalTextProviderOptions['messages'] = []
472+
473+
for (const msg of messages) {
474+
// Skip empty assistant messages (no content or empty string)
475+
if (msg.role === 'assistant') {
476+
const hasContent = Array.isArray(msg.content)
477+
? msg.content.length > 0
478+
: typeof msg.content === 'string' && msg.content.length > 0
479+
if (!hasContent) {
480+
continue
481+
}
482+
}
483+
484+
const prev = merged[merged.length - 1]
485+
if (prev && prev.role === msg.role) {
486+
// Normalize both contents to arrays and concatenate
487+
const prevBlocks = Array.isArray(prev.content)
488+
? prev.content
489+
: typeof prev.content === 'string' && prev.content
490+
? [{ type: 'text' as const, text: prev.content }]
491+
: []
492+
const msgBlocks = Array.isArray(msg.content)
493+
? msg.content
494+
: typeof msg.content === 'string' && msg.content
495+
? [{ type: 'text' as const, text: msg.content }]
496+
: []
497+
prev.content = [...prevBlocks, ...msgBlocks]
498+
} else {
499+
merged.push({ ...msg })
500+
}
501+
}
502+
503+
// De-duplicate tool_result blocks with the same tool_use_id.
504+
// This can happen when the core layer generates tool results from both
505+
// the tool-result part and the tool-call part's output field.
506+
for (const msg of merged) {
507+
if (Array.isArray(msg.content)) {
508+
const seenToolResultIds = new Set<string>()
509+
msg.content = msg.content.filter((block: any) => {
510+
if (block.type === 'tool_result' && block.tool_use_id) {
511+
if (seenToolResultIds.has(block.tool_use_id)) {
512+
return false // Remove duplicate
513+
}
514+
seenToolResultIds.add(block.tool_use_id)
515+
}
516+
return true
517+
})
518+
}
519+
}
520+
521+
return merged
454522
}
455523

456524
private async *processAnthropicStream(
@@ -473,6 +541,9 @@ export class AnthropicTextAdapter<
473541
let stepId: string | null = null
474542
let hasEmittedRunStarted = false
475543
let hasEmittedTextMessageStart = false
544+
let hasEmittedRunFinished = false
545+
// Track current content block type for proper content_block_stop handling
546+
let currentBlockType: string | null = null
476547

477548
try {
478549
for await (const event of stream) {
@@ -488,6 +559,7 @@ export class AnthropicTextAdapter<
488559
}
489560

490561
if (event.type === 'content_block_start') {
562+
currentBlockType = event.content_block.type
491563
if (event.content_block.type === 'tool_use') {
492564
currentToolIndex++
493565
toolCallsMap.set(currentToolIndex, {
@@ -572,59 +644,71 @@ export class AnthropicTextAdapter<
572644
}
573645
}
574646
} else if (event.type === 'content_block_stop') {
575-
const existing = toolCallsMap.get(currentToolIndex)
576-
if (existing) {
577-
// If tool call wasn't started yet (no args), start it now
578-
if (!existing.started) {
579-
existing.started = true
647+
if (currentBlockType === 'tool_use') {
648+
const existing = toolCallsMap.get(currentToolIndex)
649+
if (existing) {
650+
// If tool call wasn't started yet (no args), start it now
651+
if (!existing.started) {
652+
existing.started = true
653+
yield {
654+
type: 'TOOL_CALL_START',
655+
toolCallId: existing.id,
656+
toolName: existing.name,
657+
model,
658+
timestamp,
659+
index: currentToolIndex,
660+
}
661+
}
662+
663+
// Emit TOOL_CALL_END
664+
let parsedInput: unknown = {}
665+
try {
666+
const parsed = existing.input ? JSON.parse(existing.input) : {}
667+
parsedInput = parsed && typeof parsed === 'object' ? parsed : {}
668+
} catch {
669+
parsedInput = {}
670+
}
671+
580672
yield {
581-
type: 'TOOL_CALL_START',
673+
type: 'TOOL_CALL_END',
582674
toolCallId: existing.id,
583675
toolName: existing.name,
584676
model,
585677
timestamp,
586-
index: currentToolIndex,
678+
input: parsedInput,
587679
}
588-
}
589680

590-
// Emit TOOL_CALL_END
591-
let parsedInput: unknown = {}
592-
try {
593-
const parsed = existing.input ? JSON.parse(existing.input) : {}
594-
parsedInput = parsed && typeof parsed === 'object' ? parsed : {}
595-
} catch {
596-
parsedInput = {}
681+
// Reset so a new TEXT_MESSAGE_START is emitted if text follows tool calls
682+
hasEmittedTextMessageStart = false
597683
}
598-
599-
yield {
600-
type: 'TOOL_CALL_END',
601-
toolCallId: existing.id,
602-
toolName: existing.name,
603-
model,
604-
timestamp,
605-
input: parsedInput,
684+
} else {
685+
// Emit TEXT_MESSAGE_END only for text blocks (not tool_use blocks)
686+
if (hasEmittedTextMessageStart && accumulatedContent) {
687+
yield {
688+
type: 'TEXT_MESSAGE_END',
689+
messageId,
690+
model,
691+
timestamp,
692+
}
606693
}
607694
}
608-
609-
// Emit TEXT_MESSAGE_END if we had text content
610-
if (hasEmittedTextMessageStart && accumulatedContent) {
695+
currentBlockType = null
696+
} else if (event.type === 'message_stop') {
697+
// Only emit RUN_FINISHED from message_stop if message_delta didn't already emit one.
698+
// message_delta carries the real stop_reason (tool_use, end_turn, etc.),
699+
// while message_stop is just a completion signal.
700+
if (!hasEmittedRunFinished) {
611701
yield {
612-
type: 'TEXT_MESSAGE_END',
613-
messageId,
702+
type: 'RUN_FINISHED',
703+
runId,
614704
model,
615705
timestamp,
706+
finishReason: 'stop',
616707
}
617708
}
618-
} else if (event.type === 'message_stop') {
619-
yield {
620-
type: 'RUN_FINISHED',
621-
runId,
622-
model,
623-
timestamp,
624-
finishReason: 'stop',
625-
}
626709
} else if (event.type === 'message_delta') {
627710
if (event.delta.stop_reason) {
711+
hasEmittedRunFinished = true
628712
switch (event.delta.stop_reason) {
629713
case 'tool_use': {
630714
yield {

packages/typescript/ai-anthropic/src/model-meta.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ interface ModelMeta<
1919
supports: {
2020
input: Array<'text' | 'image' | 'audio' | 'video' | 'document'>
2121
extended_thinking?: boolean
22+
adaptive_thinking?: boolean
2223
priority_tier?: boolean
2324
}
2425
context_window?: number

0 commit comments

Comments
 (0)