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
6 changes: 6 additions & 0 deletions .changeset/fix-client-tool-call-input-complete.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@tanstack/ai': patch
'@tanstack/ai-client': patch
---

Keep a successful tool-call part at `input-complete` after its result is applied. A client tool result set the tool-call part to `complete`, which raced the finalize-stream safety net and depended on async ordering (regressed in #1233). The tool-call part now stays at its terminal `input-complete` state, and the completed result stays on the separate tool-result part.
11 changes: 9 additions & 2 deletions packages/ai/src/activities/chat/stream/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,12 +434,19 @@ export class StreamProcessor {
return
}

// Step 1: Update the tool-call part's output field (for UI rendering)
// Step 1: Update the tool-call part's output field (for UI rendering).
// A successful result keeps the tool-call part at its terminal
// 'input-complete' state (the model's request is complete); the result
// itself lives in the tool-result part created in Step 2. Setting the
// tool-call part to 'complete' here raced the finalize-stream safety net
// (completeToolCall), so the visible state flipped depending on async
// ordering (#1233). Only an execution error makes the tool-call part
// itself terminal with 'error'.
let updatedMessages = updateToolCallWithOutput(
this.messages,
toolCallId,
output,
error ? 'error' : undefined,
error ? 'error' : 'input-complete',
error,
)

Expand Down
8 changes: 6 additions & 2 deletions packages/ai/tests/stream-processor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1783,7 +1783,9 @@ describe('StreamProcessor', () => {
(p) => p.type === 'tool-call',
) as ToolCallPart
expect((toolCallPart as any).output).toEqual({ temp: 72 })
expect(toolCallPart.state).toBe('complete')
// The tool-call part stays at its terminal 'input-complete' state; the
// completed result lives in the separate tool-result part below.
expect(toolCallPart.state).toBe('input-complete')

const toolResultPart = messages[0]!.parts.find(
(p) => p.type === 'tool-result',
Expand Down Expand Up @@ -3849,7 +3851,9 @@ describe('StreamProcessor', () => {
?.parts.find(
(p): p is ToolCallPart => p.type === 'tool-call' && p.id === 'tc-1',
)
expect(updated?.state).toBe('complete')
// The tool-call part keeps its terminal 'input-complete' state; the
// result is carried by the tool-result part.
expect(updated?.state).toBe('input-complete')
expect(updated?.output).toEqual({ temp: 72 })

warn.mockRestore()
Expand Down
Loading