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
18 changes: 18 additions & 0 deletions .changeset/immutable-tool-call-updates.md
Original file line number Diff line number Diff line change
@@ -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`.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
}
}

Expand Down Expand Up @@ -181,7 +185,8 @@ export function updateToolCallState(
)

if (toolCallPart) {
toolCallPart.state = state
const index = parts.indexOf(toolCallPart)
parts[index] = { ...toolCallPart, state }
}

return { ...msg, parts }
Expand All @@ -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',
}
}

Expand All @@ -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 }
Expand Down
96 changes: 96 additions & 0 deletions packages/typescript/ai/tests/message-updaters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
})