diff --git a/.changeset/immutable-tool-call-updates.md b/.changeset/immutable-tool-call-updates.md new file mode 100644 index 000000000..42b2702bb --- /dev/null +++ b/.changeset/immutable-tool-call-updates.md @@ -0,0 +1,18 @@ +--- +'@tanstack/ai': patch +--- + +fix(ai): produce new object references in tool-call message updaters + +`updateToolCallApproval`, `updateToolCallState`, `updateToolCallWithOutput`, +and `updateToolCallApprovalResponse` previously mutated the found tool-call +part in-place (`toolCallPart.state = ...`) after spreading the parts array. +The shallow `[...msg.parts]` copy created a new array but preserved the +original object references, so frameworks that rely on reference identity +for change detection (Svelte 5 proxies, Vue 3 reactivity, etc.) could not +observe the updates. + +Each function now replaces the part at its index with a spread copy +(`parts[index] = { ...toolCallPart, ...changes }`), producing a fresh +object on every update. This aligns with the pattern already used by +`updateToolCallPart`, `updateTextPart`, and `updateThinkingPart`. diff --git a/packages/typescript/ai/src/activities/chat/stream/message-updaters.ts b/packages/typescript/ai/src/activities/chat/stream/message-updaters.ts index 80b94d59a..57ffbe2e6 100644 --- a/packages/typescript/ai/src/activities/chat/stream/message-updaters.ts +++ b/packages/typescript/ai/src/activities/chat/stream/message-updaters.ts @@ -150,10 +150,14 @@ export function updateToolCallApproval( ) if (toolCallPart) { - toolCallPart.state = 'approval-requested' - toolCallPart.approval = { - id: approvalId, - needsApproval: true, + const index = parts.indexOf(toolCallPart) + parts[index] = { + ...toolCallPart, + state: 'approval-requested', + approval: { + id: approvalId, + needsApproval: true, + }, } } @@ -181,7 +185,8 @@ export function updateToolCallState( ) if (toolCallPart) { - toolCallPart.state = state + const index = parts.indexOf(toolCallPart) + parts[index] = { ...toolCallPart, state } } return { ...msg, parts } @@ -206,11 +211,11 @@ export function updateToolCallWithOutput( ) if (toolCallPart) { - toolCallPart.output = errorText ? { error: errorText } : output - if (state) { - toolCallPart.state = state - } else { - toolCallPart.state = 'input-complete' + const index = parts.indexOf(toolCallPart) + parts[index] = { + ...toolCallPart, + output: errorText ? { error: errorText } : output, + state: state ?? 'input-complete', } } @@ -235,8 +240,12 @@ export function updateToolCallApprovalResponse( ) if (toolCallPart && toolCallPart.approval) { - toolCallPart.approval.approved = approved - toolCallPart.state = 'approval-responded' + const index = parts.indexOf(toolCallPart) + parts[index] = { + ...toolCallPart, + approval: { ...toolCallPart.approval, approved }, + state: 'approval-responded', + } } return { ...msg, parts } diff --git a/packages/typescript/ai/tests/message-updaters.test.ts b/packages/typescript/ai/tests/message-updaters.test.ts index 5de1a031e..23344b0c4 100644 --- a/packages/typescript/ai/tests/message-updaters.test.ts +++ b/packages/typescript/ai/tests/message-updaters.test.ts @@ -864,5 +864,101 @@ describe('message-updaters', () => { expect(result[0]?.parts).not.toBe(originalParts) expect(messages[0]?.parts).toBe(originalParts) }) + + it('updateToolCallApproval should not mutate the original tool-call part', () => { + const originalPart: ToolCallPart = { + type: 'tool-call', + id: 'call-1', + name: 'deleteFile', + arguments: '{"path":"/tmp/file"}', + state: 'input-complete', + } + const messages = [ + createMessage('msg-1', 'assistant', [originalPart]), + ] + + const result = updateToolCallApproval(messages, 'msg-1', 'call-1', 'approval-1') + + // Original part must be unchanged + expect(originalPart.state).toBe('input-complete') + expect(originalPart.approval).toBeUndefined() + + // Result must have new values + const resultPart = result[0]?.parts[0] as ToolCallPart + expect(resultPart).not.toBe(originalPart) + expect(resultPart.state).toBe('approval-requested') + expect(resultPart.approval).toEqual({ id: 'approval-1', needsApproval: true }) + }) + + it('updateToolCallState should not mutate the original tool-call part', () => { + const originalPart: ToolCallPart = { + type: 'tool-call', + id: 'call-1', + name: 'getWeather', + arguments: '{}', + state: 'input-streaming', + } + const messages = [ + createMessage('msg-1', 'assistant', [originalPart]), + ] + + const result = updateToolCallState(messages, 'msg-1', 'call-1', 'input-complete') + + expect(originalPart.state).toBe('input-streaming') + + const resultPart = result[0]?.parts[0] as ToolCallPart + expect(resultPart).not.toBe(originalPart) + expect(resultPart.state).toBe('input-complete') + }) + + it('updateToolCallWithOutput should not mutate the original tool-call part', () => { + const originalPart: ToolCallPart = { + type: 'tool-call', + id: 'call-1', + name: 'getWeather', + arguments: '{}', + state: 'input-complete', + } + const messages = [ + createMessage('msg-1', 'assistant', [originalPart]), + ] + const output = { temperature: 20 } + + const result = updateToolCallWithOutput(messages, 'call-1', output) + + expect(originalPart.output).toBeUndefined() + expect(originalPart.state).toBe('input-complete') + + const resultPart = result[0]?.parts[0] as ToolCallPart + expect(resultPart).not.toBe(originalPart) + expect(resultPart.output).toEqual(output) + }) + + it('updateToolCallApprovalResponse should not mutate the original tool-call part', () => { + const originalApproval = { id: 'approval-1', needsApproval: true as const } + const originalPart: ToolCallPart = { + type: 'tool-call', + id: 'call-1', + name: 'deleteFile', + arguments: '{}', + state: 'approval-requested', + approval: originalApproval, + } + const messages = [ + createMessage('msg-1', 'assistant', [originalPart]), + ] + + const result = updateToolCallApprovalResponse(messages, 'approval-1', true) + + // Original part and approval must be unchanged + expect(originalPart.state).toBe('approval-requested') + expect(originalApproval.approved).toBeUndefined() + + const resultPart = result[0]?.parts[0] as ToolCallPart + expect(resultPart).not.toBe(originalPart) + expect(resultPart.approval).not.toBe(originalApproval) + expect(resultPart.state).toBe('approval-responded') + expect(resultPart.approval?.approved).toBe(true) + }) }) })