diff --git a/apps/studio/state/ai-assistant-state.test.ts b/apps/studio/state/ai-assistant-state.test.ts new file mode 100644 index 00000000000..4fb412ffe83 --- /dev/null +++ b/apps/studio/state/ai-assistant-state.test.ts @@ -0,0 +1,46 @@ +import { proxy, ref } from 'valtio/vanilla' +import { describe, expect, it } from 'vitest' + +import { sanitizeForCloning } from './ai-assistant-state' + +describe('AI assistant chat message sync', () => { + // FE-3954: syncing the live array into valtio corrupted it with Proxies, breaking structuredClone in addToolApprovalResponse + it('does not corrupt the live SDK message array when synced into valtio state', () => { + const state = proxy<{ chats: Record; chatInstances: Record }>({ + chats: {}, + chatInstances: {}, + }) + state.chats['chat-1'] = { id: 'chat-1', messages: [] } + + const liveSdkMessages = [ + { + id: 'message-1', + role: 'assistant', + parts: [ + { type: 'text', text: "Sure, here's a query" }, + { + type: 'tool-execute_sql', + toolCallId: 'tool-1', + state: 'approval-requested', + approval: { id: 'approval-1' }, + }, + ], + }, + ] + state.chatInstances['chat-1'] = ref({ messages: liveSdkMessages }) + + const chat = state.chats['chat-1'] + chat.messages = liveSdkMessages.map((message) => sanitizeForCloning(message)) + + // mirrors addToolApprovalResponse's own update logic + const lastMessage = liveSdkMessages[liveSdkMessages.length - 1] + const updatedParts = lastMessage.parts.map((part: any) => + part.state === 'approval-requested' && part.approval?.id === 'approval-1' + ? { ...part, state: 'approval-responded', approval: { id: 'approval-1', approved: true } } + : part + ) + const replacedMessage = { ...lastMessage, parts: updatedParts } + + expect(() => structuredClone(replacedMessage)).not.toThrow() + }) +}) diff --git a/apps/studio/state/ai-assistant-state.tsx b/apps/studio/state/ai-assistant-state.tsx index c083fa10e52..9f9599b32f9 100644 --- a/apps/studio/state/ai-assistant-state.tsx +++ b/apps/studio/state/ai-assistant-state.tsx @@ -152,7 +152,7 @@ async function clearStorage(): Promise { // Helper function to sanitize objects to ensure they're cloneable // Issue due to addToolResult -function sanitizeForCloning(obj: any): any { +export function sanitizeForCloning(obj: any): any { if (obj === null || obj === undefined) return obj if (typeof obj !== 'object') return obj return JSON.parse(JSON.stringify(obj)) @@ -329,7 +329,8 @@ function createChatInstance( const messages = chatInstance.messages const chat = state.chats[options.id] if (chat) { - chat.messages = messages + // Clone first — valtio's proxy() mutates nested properties in place and would corrupt the SDK's live array + chat.messages = messages.map((message) => sanitizeForCloning(message)) chat.updatedAt = new Date() }