diff --git a/.changeset/quiet-append-drain.md b/.changeset/quiet-append-drain.md new file mode 100644 index 000000000..7272da3da --- /dev/null +++ b/.changeset/quiet-append-drain.md @@ -0,0 +1,6 @@ +--- +'@tanstack/ai': patch +'@tanstack/ai-client': patch +--- + +Keep `append()` pending until the HTTP response is fully processed, including later `RUN_FINISHED` events in the same agent loop. diff --git a/docs/api/ai-client.md b/docs/api/ai-client.md index 16456962b..7f7f47a6e 100644 --- a/docs/api/ai-client.md +++ b/docs/api/ai-client.md @@ -176,6 +176,8 @@ export async function POST(request: Request) { Appends a message to the conversation. If you pass a `UIMessage`, `append` copies `uiMessage.metadata` onto the stored message. +`append()` resolves after the full HTTP response is processed. A `RUN_FINISHED` with `finishReason: "tool_calls"` does not end the wait when the agent loop continues in that response. + ```typescript import { client } from "./client"; import type { UIMessage } from "@tanstack/ai-client"; diff --git a/packages/ai-client/src/chat-client.ts b/packages/ai-client/src/chat-client.ts index 0d320cdd8..193b0f5af 100644 --- a/packages/ai-client/src/chat-client.ts +++ b/packages/ai-client/src/chat-client.ts @@ -164,6 +164,16 @@ function resolveTransport(transport: { throw new Error('ChatClient: either `connection` or `fetcher` is required.') } +function connectionDrainsOnSend(connection: ConnectionAdapter): boolean { + return 'connect' in connection +} + +function isIntermediateToolTurn(chunk: StreamChunk): boolean { + if (chunk.type !== 'RUN_FINISHED') return false + if (chunk.outcome?.type === 'interrupt') return false + return tanstackMetadata(chunk)?.finishReason === 'tool_calls' +} + export interface NormalizedQueueConfig { whenBusy: WhenBusy drain: 'fifo' | 'batch' @@ -416,6 +426,12 @@ export class ChatClient< private continuationPending = false private subscriptionAbortController: AbortController | null = null private processingResolve: (() => void) | null = null + /** + * `connect()` adapters push the full HTTP body into the subscribe queue, then + * wait until that queue is idle. After `send()` returns, every chunk from this + * request has been processed. Subscribe/send sockets do not drain that way. + */ + private connectionDrainsOnSend = false private errorReportedGeneration: number | null = null private streamGeneration = 0 private continuationGeneration = 0 @@ -518,7 +534,9 @@ export class ChatClient< this.byokProvider = options.byokProvider this.context = options.context this.queueConfig = normalizeQueueOption(options.queue) - this.connection = normalizeConnectionAdapter(resolveTransport(options)) + const transport = resolveTransport(options) + this.connectionDrainsOnSend = connectionDrainsOnSend(transport) + this.connection = normalizeConnectionAdapter(transport) // Build client tools map this.clientToolsRef = { current: new Map() } @@ -1140,7 +1158,9 @@ export class ChatClient< this.clearedStreamTracker.onSessionRunError() } this.setSessionGenerating(this.activeRunIds.size > 0) - if (options?.resolveProcessing !== false) { + const skipProcessingResolve = + chunk.type === 'RUN_FINISHED' && isIntermediateToolTurn(chunk) + if (options?.resolveProcessing !== false && !skipProcessingResolve) { this.resolveProcessing() } } @@ -2344,6 +2364,14 @@ export class ChatClient< return false } + // connect() send() already waited until the subscribe queue was idle. + // Kick the processing wait so a stream that ends on tool_calls (no + // interrupt / stop) cannot hang. Subscribe/send sockets still wait for + // a request-ending terminal below. + if (this.connectionDrainsOnSend) { + this.resolveProcessing() + } + // Wait for subscription loop to finish processing all chunks await processingComplete @@ -3045,12 +3073,12 @@ export class ChatClient< this.resetSessionGenerating() this.setIsSubscribed(false) this.setConnectionStatus('disconnected') - this.connection = normalizeConnectionAdapter( - resolveTransport({ - connection: options.connection, - fetcher: options.fetcher, - }), - ) + const transport = resolveTransport({ + connection: options.connection, + fetcher: options.fetcher, + }) + this.connectionDrainsOnSend = connectionDrainsOnSend(transport) + this.connection = normalizeConnectionAdapter(transport) if (wasSubscribed) { this.subscribe() diff --git a/packages/ai-client/src/connection-adapters.ts b/packages/ai-client/src/connection-adapters.ts index 63541ebe0..127a2bb66 100644 --- a/packages/ai-client/src/connection-adapters.ts +++ b/packages/ai-client/src/connection-adapters.ts @@ -1055,6 +1055,27 @@ export function normalizeConnectionAdapter( } } + async function waitUntilSubscriberIdle( + abortSignal?: AbortSignal, + ): Promise { + const idle = () => + activeBuffer.length === 0 && + (activeWaiters.length > 0 || abortSignal?.aborted) + for (let i = 0; i < 16 && !abortSignal?.aborted; i++) { + if (idle()) return + if (activeBuffer.length === 0 && activeWaiters.length === 0) return + await Promise.resolve() + } + let macrotaskWaits = 0 + while (!abortSignal?.aborted) { + if (idle()) return + if (activeBuffer.length === 0 && activeWaiters.length === 0) return + await new Promise((resolve) => setTimeout(resolve, 0)) + macrotaskWaits++ + if (activeWaiters.length === 0 && macrotaskWaits >= 32) return + } + } + return { subscribe(abortSignal?: AbortSignal): AsyncIterable { // Transfer ownership to the latest subscriber so only one active @@ -1162,6 +1183,7 @@ export function normalizeConnectionAdapter( } throw err } + await waitUntilSubscriberIdle(abortSignal) }, // Expose joinRun only when the underlying connection is resumable. Require // a real function — `'joinRun' in connection` is true for diff --git a/packages/ai-client/tests/chat-client.test.ts b/packages/ai-client/tests/chat-client.test.ts index 67429010b..44b1efbbd 100644 --- a/packages/ai-client/tests/chat-client.test.ts +++ b/packages/ai-client/tests/chat-client.test.ts @@ -2306,6 +2306,58 @@ describe('ChatClient', () => { expect(messages[0]?.id).toBeTruthy() expect(messages[0]?.createdAt).toBeInstanceOf(Date) }) + + it('keeps append pending through an intermediate tool_calls RUN_FINISHED until the interrupt', async () => { + const adapter: ConnectConnectionAdapter = { + async *connect(_messages, _data, _signal, ctx) { + const runId = ctx?.runId ?? 'run-1' + const threadId = ctx?.threadId ?? 'thread-1' + yield { + type: EventType.RUN_STARTED, + runId, + threadId, + timestamp: Date.now(), + } + yield { + type: EventType.RUN_FINISHED, + runId, + threadId, + timestamp: Date.now(), + metadata: { tanstack: { finishReason: 'tool_calls' } }, + } + yield { + type: EventType.RUN_STARTED, + runId: 'provider-2', + threadId, + timestamp: Date.now(), + } + yield { + type: EventType.RUN_FINISHED, + runId: 'provider-2', + threadId, + timestamp: Date.now(), + outcome: { + type: 'interrupt', + interrupts: [{ id: 'interrupt-1', reason: 'client_tool_input' }], + }, + } + }, + } + const client = new ChatClient({ + connection: adapter, + threadId: 'thread-1', + }) + + await client.append({ + role: 'user', + content: 'Notify me', + }) + + expect(client.getPendingInterrupts()).toEqual([ + expect.objectContaining({ id: 'interrupt-1' }), + ]) + expect(client.getResumeState()?.runId).toBeTruthy() + }) }) describe('reload', () => { diff --git a/packages/ai/src/activities/chat/stream/processor.ts b/packages/ai/src/activities/chat/stream/processor.ts index 8b6f5bc37..2fde22970 100644 --- a/packages/ai/src/activities/chat/stream/processor.ts +++ b/packages/ai/src/activities/chat/stream/processor.ts @@ -1647,8 +1647,14 @@ export class StreamProcessor { } if (this.activeRuns.size === 0) { - this.isDone = true this.completeAllToolCalls() + const isIntermediateToolTurn = + this.finishReason === 'tool_calls' && + chunk.outcome?.type !== 'interrupt' + if (isIntermediateToolTurn) { + return + } + this.isDone = true this.finalizeStream() } } @@ -2344,6 +2350,7 @@ export class StreamProcessor { * @see docs/chat-architecture.md#single-shot-text-response — Finalization step */ finalizeStream(): void { + this.isDone = true let lastAssistantMessage: UIMessage | undefined // Finalize ALL active messages diff --git a/packages/ai/tests/stream-processor.test.ts b/packages/ai/tests/stream-processor.test.ts index 61a37de65..e2efbe07b 100644 --- a/packages/ai/tests/stream-processor.test.ts +++ b/packages/ai/tests/stream-processor.test.ts @@ -2976,7 +2976,9 @@ describe('StreamProcessor', () => { expect(state.toolCalls.size).toBe(1) expect(state.toolCallOrder).toEqual(['tc-1']) expect(state.finishReason).toBe('tool_calls') - expect(state.done).toBe(true) + expect(state.done).toBe(false) + processor.finalizeStream() + expect(processor.getState().done).toBe(true) }) it('should return independent copies (mutations do not affect internal state)', () => { @@ -4603,6 +4605,26 @@ describe('StreamProcessor', () => { expect(processor.getState().done).toBe(true) }) + it('does not fire onStreamEnd on a sequential tool_calls terminal', () => { + const events = spyEvents() + const processor = new StreamProcessor({ events }) + + processor.processChunk(ev.runStarted('run-1')) + processor.processChunk(ev.textStart('msg-1')) + processor.processChunk(ev.textContent('calling', 'msg-1')) + processor.processChunk(ev.runFinished('tool_calls', 'run-1')) + + expect(events.onStreamEnd).not.toHaveBeenCalled() + expect(processor.getState().done).toBe(false) + + processor.processChunk(ev.runStarted('run-2')) + processor.processChunk(ev.textContent(' done', 'msg-1')) + processor.processChunk(ev.runFinished('stop', 'run-2')) + + expect(events.onStreamEnd).toHaveBeenCalledTimes(1) + expect(processor.getState().done).toBe(true) + }) + it('single run should finalize normally (backward compat)', () => { const events = spyEvents() const processor = new StreamProcessor({ events }) @@ -4656,6 +4678,8 @@ describe('StreamProcessor', () => { expect(processor.getState().toolCalls.get('tc-a')?.state).toBe( 'input-complete', ) + expect(processor.getState().done).toBe(false) + processor.finalizeStream() expect(processor.getState().done).toBe(true) }) diff --git a/scripts/lovable-gateway.models.json b/scripts/lovable-gateway.models.json index 8a193376e..79c6babbc 100644 --- a/scripts/lovable-gateway.models.json +++ b/scripts/lovable-gateway.models.json @@ -12,15 +12,8 @@ "max_tokens": 65536, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -85,15 +78,8 @@ "max_tokens": 65536, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text", - "image" - ] + "input": ["text", "image", "video"], + "output": ["text", "image"] }, "pricing": { "input": { @@ -150,15 +136,8 @@ "max_tokens": 65536, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -222,12 +201,8 @@ "context_window": 8192, "max_tokens": 16384, "modalities": { - "input": [ - "text" - ], - "output": [ - "audio" - ] + "input": ["text"], + "output": ["audio"] }, "pricing": { "input": { @@ -265,12 +240,8 @@ "max_tokens": 16384, "knowledge": "2025-01", "modalities": { - "input": [ - "text" - ], - "output": [ - "audio" - ] + "input": ["text"], + "output": ["audio"] }, "pricing": { "input": { @@ -308,15 +279,8 @@ "max_tokens": 65536, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -406,12 +370,8 @@ "max_tokens": 16384, "knowledge": "2025-01", "modalities": { - "input": [ - "text" - ], - "output": [ - "audio" - ] + "input": ["text"], + "output": ["audio"] }, "pricing": { "input": { @@ -449,15 +409,8 @@ "max_tokens": 65000, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -522,15 +475,8 @@ "max_tokens": 32768, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text", - "image" - ] + "input": ["text", "image", "video"], + "output": ["text", "image"] }, "pricing": { "input": { @@ -595,15 +541,8 @@ "max_tokens": 32768, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text", - "image" - ] + "input": ["text", "image", "video"], + "output": ["text", "image"] }, "pricing": { "input": { @@ -668,15 +607,8 @@ "max_tokens": 65000, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -740,15 +672,8 @@ "context_window": 65536, "max_tokens": 4096, "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text", - "image" - ] + "input": ["text", "image", "video"], + "output": ["text", "image"] }, "pricing": { "input": { @@ -813,12 +738,8 @@ "max_tokens": 16384, "knowledge": "2025-01", "modalities": { - "input": [ - "text" - ], - "output": [ - "audio" - ] + "input": ["text"], + "output": ["audio"] }, "pricing": { "input": { @@ -856,15 +777,8 @@ "max_tokens": 64000, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -954,15 +868,8 @@ "max_tokens": 64000, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -1027,15 +934,8 @@ "max_tokens": 64000, "knowledge": "2026-03", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -1100,12 +1000,8 @@ "max_tokens": 0, "knowledge": "2025-05", "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] + "input": ["text"], + "output": ["text"] }, "pricing": { "input": { @@ -1133,15 +1029,8 @@ "max_tokens": 0, "knowledge": "2025-11", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -1192,13 +1081,8 @@ "context_window": 0, "max_tokens": 0, "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "video" - ] + "input": ["text", "image"], + "output": ["video"] }, "pricing": { "video_duration": { @@ -1226,13 +1110,8 @@ "context_window": 0, "max_tokens": 0, "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "video" - ] + "input": ["text", "image"], + "output": ["video"] }, "pricing": { "video_duration": { @@ -1260,13 +1139,8 @@ "context_window": 0, "max_tokens": 0, "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "video" - ] + "input": ["text", "image"], + "output": ["video"] }, "pricing": { "video_duration": { @@ -1291,13 +1165,8 @@ "context_window": 16000, "max_tokens": 2000, "modalities": { - "input": [ - "text", - "audio" - ], - "output": [ - "text" - ] + "input": ["text", "audio"], + "output": ["text"] }, "pricing": { "input": { @@ -1342,12 +1211,8 @@ "context_window": 2000, "max_tokens": 0, "modalities": { - "input": [ - "text" - ], - "output": [ - "audio" - ] + "input": ["text"], + "output": ["audio"] }, "pricing": { "input": { @@ -1384,13 +1249,8 @@ "context_window": 16000, "max_tokens": 2000, "modalities": { - "input": [ - "text", - "audio" - ], - "output": [ - "text" - ] + "input": ["text", "audio"], + "output": ["text"] }, "pricing": { "input": { @@ -1436,13 +1296,8 @@ "max_tokens": 128000, "knowledge": "2024-09-30", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -1526,13 +1381,8 @@ "max_tokens": 128000, "knowledge": "2024-05-30", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -1616,13 +1466,8 @@ "max_tokens": 128000, "knowledge": "2024-05-30", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -1671,13 +1516,8 @@ "max_tokens": 128000, "knowledge": "2024-10", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -1761,13 +1601,8 @@ "max_tokens": 128000, "knowledge": "2025-08-31", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -1866,13 +1701,8 @@ "max_tokens": 128000, "knowledge": "2025-08-31", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -1956,13 +1786,8 @@ "max_tokens": 128000, "knowledge": "2025-08-31", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2011,13 +1836,8 @@ "max_tokens": 128000, "knowledge": "2025-08-31", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2081,13 +1901,8 @@ "max_tokens": 128000, "knowledge": "2025-12-01", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2186,13 +2001,8 @@ "max_tokens": 128000, "knowledge": "2025-12-01", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2256,13 +2066,8 @@ "max_tokens": 128000, "knowledge": "2026-02-16", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2361,13 +2166,8 @@ "max_tokens": 128000, "knowledge": "2026-02-16", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2466,13 +2266,8 @@ "max_tokens": 128000, "knowledge": "2026-02-16", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2570,13 +2365,8 @@ "context_window": 0, "max_tokens": 0, "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "image" - ] + "input": ["text", "image"], + "output": ["image"] }, "pricing": { "input": { @@ -2621,13 +2411,8 @@ "context_window": 0, "max_tokens": 0, "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "image" - ] + "input": ["text", "image"], + "output": ["image"] }, "pricing": { "input": { @@ -2672,12 +2457,8 @@ "context_window": 8191, "max_tokens": 0, "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] + "input": ["text"], + "output": ["text"] }, "pricing": { "input": { @@ -2704,12 +2485,8 @@ "context_window": 8191, "max_tokens": 0, "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] + "input": ["text"], + "output": ["text"] }, "pricing": { "input": { diff --git a/testing/e2e/src/lib/tools-test-tools.ts b/testing/e2e/src/lib/tools-test-tools.ts index 8cbc8c381..91a83f5a6 100644 --- a/testing/e2e/src/lib/tools-test-tools.ts +++ b/testing/e2e/src/lib/tools-test-tools.ts @@ -253,6 +253,11 @@ export const SCENARIO_LIST = [ label: 'Client Tool Input Error', category: 'basic', }, + { + id: 'invalid-client-tool-retry', + label: 'Invalid Client Tool Retry (Regression #1192)', + category: 'race', + }, // Race condition / event flow scenarios { id: 'sequential-client-tools', @@ -312,6 +317,7 @@ export function getToolsForScenario(scenario: string) { case 'client-tool-reasoning': case 'client-tool-stop': case 'client-tool-input-error': + case 'invalid-client-tool-retry': return [clientToolDefinitions.show_notification] case 'server-context': diff --git a/testing/e2e/src/routes/api.tools-test.ts b/testing/e2e/src/routes/api.tools-test.ts index b6d1e9d44..5b1daaae6 100644 --- a/testing/e2e/src/routes/api.tools-test.ts +++ b/testing/e2e/src/routes/api.tools-test.ts @@ -20,6 +20,7 @@ const providerFreeScenarios = new Set([ 'client-server-context', 'client-tool-stop', 'client-tool-input-error', + 'invalid-client-tool-retry', 'malformed-tool-arguments', 'provider-rejected-tool-call', ]) @@ -49,13 +50,20 @@ function createProviderFreeAdapter(scenario: string): AnyTextAdapter { state: undefined, toolName: 'check_status', } - : scenario === 'client-tool-input-error' + : scenario === 'client-tool-input-error' || + scenario === 'invalid-client-tool-retry' ? { arguments: '{"message":42,"type":"info"}', initialText: 'Showing a notification.', input: { message: 42, type: 'info' }, - name: 'client-tool-input-error-test', - responseText: 'Unexpected client continuation.', + name: + scenario === 'invalid-client-tool-retry' + ? 'invalid-client-tool-retry-test' + : 'client-tool-input-error-test', + responseText: + scenario === 'invalid-client-tool-retry' + ? 'Recovered after client tool input retry.' + : 'Unexpected client continuation.', result: undefined, state: undefined, toolName: 'show_notification', @@ -104,9 +112,12 @@ function createProviderFreeAdapter(scenario: string): AnyTextAdapter { const runId = options.runId ?? 'runtime-context-run' const threadId = options.threadId ?? 'runtime-context-thread' const messageId = `${runId}-message` - const hasToolResult = options.messages.some( + const toolResultCount = options.messages.filter( (message) => message.role === 'tool', - ) + ).length + const hasToolResult = toolResultCount > 0 + const retryClientTool = + scenario === 'invalid-client-tool-retry' && toolResultCount === 1 yield { type: EventType.RUN_STARTED, @@ -116,8 +127,17 @@ function createProviderFreeAdapter(scenario: string): AnyTextAdapter { timestamp: Date.now(), } - if (!hasToolResult) { - const toolCallId = `${scenario}-tool-call` + if (!hasToolResult || retryClientTool) { + const toolCallId = + scenario === 'invalid-client-tool-retry' + ? `${scenario}-tool-call-${toolResultCount + 1}` + : `${scenario}-tool-call` + const toolArguments = retryClientTool + ? '{"message":"done","type":"info"}' + : config.arguments + const toolInput = retryClientTool + ? { message: 'done', type: 'info' } + : config.input yield { type: EventType.TEXT_MESSAGE_START, @@ -150,7 +170,7 @@ function createProviderFreeAdapter(scenario: string): AnyTextAdapter { yield { type: EventType.TOOL_CALL_ARGS, toolCallId, - delta: config.arguments, + delta: toolArguments, model, timestamp: Date.now(), } @@ -159,7 +179,7 @@ function createProviderFreeAdapter(scenario: string): AnyTextAdapter { toolCallId, toolCallName: config.toolName, toolName: config.toolName, - ...(config.input === undefined ? {} : { input: config.input }), + ...(toolInput === undefined ? {} : { input: toolInput }), ...(config.result === undefined ? {} : { result: config.result, state: config.state }), diff --git a/testing/e2e/tests/tools-test/client-tool.spec.ts b/testing/e2e/tests/tools-test/client-tool.spec.ts index 5d47f98ad..7734c43de 100644 --- a/testing/e2e/tests/tools-test/client-tool.spec.ts +++ b/testing/e2e/tests/tools-test/client-tool.spec.ts @@ -20,6 +20,48 @@ import { */ test.describe('Client Tool E2E Tests', () => { + test('invalid client-tool input retries then runs the interrupt', async ({ + page, + testId, + aimockPort, + }) => { + const requests: Array = [] + page.on('request', (request) => { + if ( + request.method() === 'POST' && + request.url().includes('/api/tools-test') + ) { + requests.push(request.url()) + } + }) + + await selectScenario(page, 'invalid-client-tool-retry', testId, aimockPort) + await runTest(page) + await waitForTestComplete(page, 15000, 2) + + const metadata = await getMetadata(page) + expect(requests).toHaveLength(2) + expect(metadata.executionCompleteCount).toBe('1') + + const toolCalls = await getToolCalls(page) + expect(toolCalls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ state: 'error' }), + expect.objectContaining({ + name: 'show_notification', + state: 'complete', + }), + ]), + ) + + const responseText = (await getMessages(page)) + .flatMap((message) => message.parts) + .filter((part) => part.type === 'text') + .map((part) => part.content) + .join(' ') + expect(responseText).toContain('Recovered after client tool input retry.') + }) + test('single client tool executes and completes', async ({ page, testId,