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
9 changes: 9 additions & 0 deletions .changeset/fix-tool-name-undefined-after-approval.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@tanstack/ai': patch
---

Fix `tool_use.name: String should have at least 1 character` 400 from Anthropic when sending a follow-up message after approving a tool that needs approval (issue #532).

The agent loop's continuation re-emit of `TOOL_CALL_START` after a server-side post-approval execution now includes the AG-UI spec field `toolCallName` alongside the deprecated `toolName` alias, so the client's `StreamProcessor` records a tool-call part with a defined `name` instead of `undefined`. As a defensive measure, `StreamProcessor` also accepts the deprecated `toolName` field as a fallback when `toolCallName` is missing.

The post-approval execution also now replaces the `pendingExecution: true` placeholder tool message in the agent loop's message history with the real tool result, instead of appending a duplicate. This prevents the Anthropic adapter's `tool_result` de-dup (which keeps the first match) from discarding the real result, so the model sees the actual tool output during the post-approval streaming response.
42 changes: 34 additions & 8 deletions packages/typescript/ai/src/activities/chat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1250,6 +1250,7 @@ class TextEngine<
timestamp: Date.now(),
model: finishEvent.model,
toolCallId: result.toolCallId,
toolCallName: result.toolName,
toolName: result.toolName,
} as StreamChunk)

Expand Down Expand Up @@ -1285,14 +1286,39 @@ class TextEngine<
role: 'tool',
} as StreamChunk)

this.messages = [
...this.messages,
{
role: 'tool',
content,
toolCallId: result.toolCallId,
},
]
// If a placeholder tool message exists for this toolCallId (created by
// uiMessageToModelMessages for an approval-responded part with no
// output yet), replace it with the real result. Otherwise the LLM sees
// both messages β€” and since the Anthropic adapter dedupes tool_result
// blocks by tool_use_id keeping the first match, the placeholder wins
// and the real result is dropped (see issue #532).
const placeholderIdx = this.messages.findIndex((m) => {
if (m.role !== 'tool' || m.toolCallId !== result.toolCallId) {
return false
}
if (typeof m.content !== 'string') return false
try {
return JSON.parse(m.content)?.pendingExecution === true
} catch {
return false
}
})

const newToolMessage: ModelMessage = {
role: 'tool',
content,
toolCallId: result.toolCallId,
}

if (placeholderIdx >= 0) {
this.messages = [
...this.messages.slice(0, placeholderIdx),
newToolMessage,
...this.messages.slice(placeholderIdx + 1),
]
} else {
this.messages = [...this.messages, newToolMessage]
}
}

return chunks
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -928,7 +928,13 @@ export class StreamProcessor {
// New tool call starting
const initialState: ToolCallState = 'awaiting-input'

const toolName = chunk.toolCallName
// `toolName` is a deprecated alias for `toolCallName` (see ToolCallStartEvent
// in types.ts). Accept either so chunks from older code paths or any
// adapter that only sets the deprecated field still produce a named part.
// The type marks both as required strings, but in practice some emitters
// only set one β€” fall back via the runtime value rather than the type.
const toolName =
(chunk as { toolCallName?: string }).toolCallName ?? chunk.toolName

const newToolCall: InternalToolCallState = {
id: chunk.toolCallId,
Expand Down
93 changes: 93 additions & 0 deletions packages/typescript/ai/tests/chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,9 @@ describe('chat()', () => {
c.type === 'TOOL_CALL_START' && (c as any).toolCallId === 'call_1',
)
expect(toolStartChunks).toHaveLength(1)
// Both AG-UI spec field `toolCallName` and deprecated alias `toolName`
// must be set so consumers reading either get a valid name (issue #532).
expect((toolStartChunks[0] as any).toolCallName).toBe('getWeather')
expect((toolStartChunks[0] as any).toolName).toBe('getWeather')

const toolArgsChunks = chunks.filter(
Expand Down Expand Up @@ -789,6 +792,7 @@ describe('chat()', () => {
(c) => c.type === 'TOOL_CALL_START' && (c as any).toolCallId === id,
)
expect(starts).toHaveLength(1)
expect((starts[0] as any).toolCallName).toBe(name)
expect((starts[0] as any).toolName).toBe(name)

const argChunks = chunks.filter(
Expand Down Expand Up @@ -859,6 +863,7 @@ describe('chat()', () => {
(c as any).toolCallId === 'call_server',
)
expect(starts).toHaveLength(1)
expect((starts[0] as any).toolCallName).toBe('getWeather')
expect((starts[0] as any).toolName).toBe('getWeather')

const argChunks = chunks.filter(
Expand All @@ -882,6 +887,94 @@ describe('chat()', () => {
expect(startIdx).toBeLessThan(argsIdx)
expect(argsIdx).toBeLessThan(endIdx)
})

it('should replace pendingExecution placeholder with the real tool result and supply both toolCallName/toolName (issue #532)', async () => {
const executeSpy = vi.fn().mockReturnValue({ status: 'ok' })

const { adapter, calls } = createMockAdapter({
iterations: [
[
ev.runStarted(),
ev.textStart(),
ev.textContent('Done.'),
ev.textEnd(),
ev.runFinished('stop'),
],
],
})

// Simulate the UIMessages a client sends back after approving a tool.
// The chat activity extracts the approval decision from the
// `approval-responded` part and converts the rest into ModelMessages,
// which includes a placeholder `tool` message marked pendingExecution.
const stream = chat({
adapter,
messages: [
{
id: 'm-user',
role: 'user',
parts: [{ type: 'text', content: 'Run it' }],
},
{
id: 'm-assistant',
role: 'assistant',
parts: [
{
type: 'tool-call',
id: 'call_approval',
name: 'approvedTool',
arguments: '{"x":1}',
state: 'approval-responded',
approval: {
id: 'approval_call_approval',
needsApproval: true,
approved: true,
},
},
],
},
] as any,
tools: [
{ ...serverTool('approvedTool', executeSpy), needsApproval: true },
],
})

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

// The tool must have actually executed because the placeholder marks
// it as pendingExecution.
expect(executeSpy).toHaveBeenCalledTimes(1)

// Synthesized TOOL_CALL_START must include both `toolCallName` (AG-UI
// spec) and `toolName` (deprecated alias). Without `toolCallName` the
// chat-client's StreamProcessor would create a tool-call part with
// name=undefined and the next outbound request would fail at Anthropic
// with `tool_use.name: String should have at least 1 character`.
const toolStart = chunks.find(
(c) =>
c.type === 'TOOL_CALL_START' &&
(c as any).toolCallId === 'call_approval',
)
expect(toolStart).toBeDefined()
expect((toolStart as any).toolCallName).toBe('approvedTool')
expect((toolStart as any).toolName).toBe('approvedTool')

// The follow-up adapter call (after the tool ran) must see the real
// tool result, not the placeholder. With the placeholder still in the
// messages array, the Anthropic adapter's tool_result de-dup would
// keep the placeholder and drop the real result.
expect(calls).toHaveLength(1)
const adapterMessages = calls[0]!.messages as Array<{
role: string
content: unknown
toolCallId?: string
}>
const toolMessages = adapterMessages.filter(
(m) => m.role === 'tool' && m.toolCallId === 'call_approval',
)
expect(toolMessages).toHaveLength(1)
expect(toolMessages[0]!.content).toBe(JSON.stringify({ status: 'ok' }))
})
})

// ==========================================================================
Expand Down
26 changes: 26 additions & 0 deletions packages/typescript/ai/tests/stream-processor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1526,6 +1526,32 @@ describe('StreamProcessor', () => {
// Edge cases
// ==========================================================================
describe('edge cases', () => {
it('TOOL_CALL_START with only the deprecated `toolName` field should still produce a named tool-call part (issue #532)', () => {
const processor = new StreamProcessor()
processor.prepareAssistantMessage()

// Some chunk producers (notably the agent loop's continuation re-emit
// before the fix) only set the deprecated alias `toolName`. The
// processor must fall back to it so the resulting tool-call part has
// a defined `name` β€” otherwise the next outbound request fails with
// `tool_use.name: String should have at least 1 character` at Anthropic.
processor.processChunk({
type: 'TOOL_CALL_START',
timestamp: Date.now(),
toolCallId: 'tc-1',
toolName: 'legacyTool',
// toolCallName intentionally omitted
} as any)

const state = processor.getState()
expect(state.toolCalls.get('tc-1')?.name).toBe('legacyTool')

const toolPart = processor
.getMessages()[0]!
.parts.find((p) => p.type === 'tool-call')
expect(toolPart && (toolPart as any).name).toBe('legacyTool')
})

it('duplicate TOOL_CALL_START with same toolCallId should be a no-op', () => {
const processor = new StreamProcessor()
processor.prepareAssistantMessage()
Expand Down
15 changes: 9 additions & 6 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions testing/e2e/fixtures/tool-approval/approval.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@
"response": {
"content": "Understood, I won't add the guitar to your cart. Let me know if you change your mind."
}
},
{
"match": {
"userMessage": "[approval] follow-up: anything else?",
"sequenceIndex": 0
},
"response": {
"content": "Here is the follow-up reply: nothing else needed for now."
}
}
]
}
1 change: 1 addition & 0 deletions testing/e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
},
"dependencies": {
"@copilotkit/aimock": "^1.18.0",
"@openrouter/sdk": "0.12.14",
"@opentelemetry/api": "^1.9.0",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/ai": "workspace:*",
Expand Down
28 changes: 21 additions & 7 deletions testing/e2e/src/lib/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { createOllamaChat } from '@tanstack/ai-ollama'
import { createGroqText } from '@tanstack/ai-groq'
import { createGrokText } from '@tanstack/ai-grok'
import { createOpenRouterText } from '@tanstack/ai-openrouter'
import { HTTPClient } from '@openrouter/sdk'
import type { Provider } from '@/lib/types'

const LLMOCK_DEFAULT_BASE = process.env.LLMOCK_URL || 'http://127.0.0.1:4010'
Expand Down Expand Up @@ -87,15 +88,28 @@ export function createTextAdapter(
defaultHeaders: testHeaders,
}),
}),
openrouter: () =>
createChatOptions({
openrouter: () => {
// OpenRouter SDK exposes an HTTPClient with beforeRequest hooks. Use
// that to inject X-Test-Id, since `defaultHeaders` isn't supported and
// the SDK strips query params off `serverURL` when building per-request
// URLs (it does `new URL(path, baseURL)` which drops the search), so
// the previous `?testId=...` trick never actually reached aimock and
// multiple openrouter tests collided on the `__default__` test bucket.
const httpClient = new HTTPClient()
if (testId) {
httpClient.addHook('beforeRequest', (req) => {
const next = new Request(req)
next.headers.set('X-Test-Id', testId)
return next
})
}
return createChatOptions({
adapter: createOpenRouterText(model as 'openai/gpt-4o', DUMMY_KEY, {
// OpenRouter SDK doesn't support defaultHeaders, so pass testId via query param
serverURL: testId
? `${openaiUrl}?testId=${encodeURIComponent(testId)}`
: openaiUrl,
serverURL: openaiUrl,
httpClient,
}),
}),
})
},
elevenlabs: () => {
throw new Error(
'ElevenLabs has no text/chat adapter β€” use createTTSAdapter or createTranscriptionAdapter.',
Expand Down
23 changes: 23 additions & 0 deletions testing/e2e/tests/tool-approval.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,29 @@ for (const provider of providersFor('tool-approval')) {
await waitForAssistantText(page, 'added')
})

test('follow-up message after approval does not produce empty tool_use.name (issue #532)', async ({
page,
testId,
aimockPort,
}) => {
await page.goto(featureUrl(provider, 'tool-approval', testId, aimockPort))

await sendMessage(page, '[approval] add the stratocaster to my cart')

await expect(page.getByTestId('approval-prompt-addToCart')).toBeVisible({
timeout: 20000,
})
await approveToolCall(page, 'addToCart')
await waitForAssistantText(page, 'added')

// The approved tool call now lives in message history. Sending a new
// message previously produced a 400 from Anthropic because the
// tool_use block had an empty `name` (issue #532). The follow-up must
// round-trip without error.
await sendMessage(page, '[approval] follow-up: anything else?')
await waitForAssistantText(page, 'follow-up')
})

test('handles denial', async ({ page, testId, aimockPort }) => {
await page.goto(featureUrl(provider, 'tool-approval', testId, aimockPort))

Expand Down
Loading