import { fireEvent, render, screen } from '@testing-library/react'
import type { UIMessage } from 'ai'
import { useState } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Message } from './Message'
const { renderQuery, renderFunction, renderMarkdown } = vi.hoisted(() => ({
renderQuery: vi.fn(),
renderFunction: vi.fn(),
renderMarkdown: vi.fn(),
}))
// Keep the message/context/part pipeline real, substituting stateful probes for the
// expensive leaves so we can detect both extra renders and lost local state.
vi.mock('./AssistantQueryCell', () => ({
AssistantQueryCell: (props: { initialResult?: { rows: unknown[] }; onApprove?: () => void }) => {
renderQuery(props)
const [runs, setRuns] = useState(0)
return (
)
},
}))
vi.mock('./EdgeFunctionRenderer', () => ({
EdgeFunctionRenderer: (props: { code: string }) => {
renderFunction(props)
return {props.code}
},
}))
vi.mock('./MessageMarkdown', () => ({
MessageMarkdown: ({ children }: { children: string }) => {
renderMarkdown(children)
return {children}
},
}))
vi.mock('./Message.Actions', () => ({ MessageActions: () => null }))
const initialMessage: UIMessage = {
id: 'assistant-1',
role: 'assistant',
parts: [
{
type: 'tool-execute_sql',
toolCallId: 'query-1',
state: 'approval-requested',
approval: { id: 'approval-1' },
input: { sql: 'select 1', label: 'Query', view: 'table' },
},
{
type: 'tool-deploy_edge_function',
toolCallId: 'function-1',
state: 'input-available',
input: {
code: 'Deno.serve(() => new Response("hello"))',
label: 'Function',
functionName: 'hello',
},
},
{ type: 'text', text: 'Working' },
],
}
const callbacks = {
onDelete: vi.fn(),
onEdit: vi.fn(),
onBranch: vi.fn(),
onCancelEdit: vi.fn(),
addToolApprovalResponse: vi.fn(),
}
function FeedMessage({
message = initialMessage,
isLoading = true,
addToolApprovalResponse = callbacks.addToolApprovalResponse,
}: {
message?: UIMessage
isLoading?: boolean
addToolApprovalResponse?: typeof callbacks.addToolApprovalResponse
}) {
return (
)
}
describe('assistant feed rendering', () => {
beforeEach(() => vi.clearAllMocks())
it('does not rerender unchanged history when the parent renders', () => {
const { rerender } = render()
const counts = [
renderQuery.mock.calls.length,
renderFunction.mock.calls.length,
renderMarkdown.mock.calls.length,
]
rerender()
expect([
renderQuery.mock.calls.length,
renderFunction.mock.calls.length,
renderMarkdown.mock.calls.length,
]).toEqual(counts)
})
it('updates streamed text without rerendering cloned tools or losing their local state', () => {
const { rerender } = render()
fireEvent.click(screen.getByRole('button', { name: 'Run count: 0' }))
const queryRenders = renderQuery.mock.calls.length
const functionRenders = renderFunction.mock.calls.length
const updated = structuredClone(initialMessage)
updated.parts[2] = { type: 'text', text: 'Working on the next step' }
rerender()
expect(screen.getByText('Working on the next step')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Run count: 1' })).toBeInTheDocument()
expect(renderQuery).toHaveBeenCalledTimes(queryRenders)
expect(renderFunction).toHaveBeenCalledTimes(functionRenders)
})
it('updates tool output and code when their content changes', () => {
const { rerender } = render()
const updated = structuredClone(initialMessage)
updated.parts[0] = {
type: 'tool-execute_sql',
toolCallId: 'query-1',
state: 'output-available',
input: { sql: 'select 1', label: 'Query', view: 'table' },
output: [{ value: 1 }],
}
updated.parts[1] = {
type: 'tool-deploy_edge_function',
toolCallId: 'function-1',
state: 'input-available',
input: { code: 'updated code', label: 'Function', functionName: 'hello' },
}
rerender()
expect(screen.getByText('[{"value":1}]')).toBeInTheDocument()
expect(screen.getByText('updated code')).toBeInTheDocument()
})
it('uses the latest approval callback even when the tool part is unchanged', () => {
const { rerender } = render()
const approve = vi.fn()
rerender()
fireEvent.click(screen.getByRole('button', { name: 'Approve query' }))
expect(approve).toHaveBeenCalledWith({ id: 'approval-1', approved: true })
expect(callbacks.addToolApprovalResponse).not.toHaveBeenCalled()
})
it('retains query state when streaming completes', () => {
const { rerender } = render()
fireEvent.click(screen.getByRole('button', { name: 'Run count: 0' }))
rerender()
expect(screen.getByRole('button', { name: 'Run count: 1' })).toBeInTheDocument()
})
it('finishes reasoning when the SDK mutates the first streamed part before publishing a snapshot', () => {
const reasoning = {
type: 'reasoning' as const,
text: 'Looking at the schema',
state: 'streaming' as 'streaming' | 'done',
}
const message: UIMessage = { id: 'reasoning-1', role: 'assistant', parts: [reasoning] }
const { rerender } = render()
// Expand the tool group so the reasoning row itself is rendered
fireEvent.click(screen.getByRole('button', { name: 'Thinking...' }))
// The group header and the reasoning row
expect(screen.getAllByText('Thinking...')).toHaveLength(2)
// Chat.pushMessage exposes the initial object; subsequent replaceMessage calls clone it.
reasoning.state = 'done'
rerender()
expect(screen.queryByText('Thinking...')).not.toBeInTheDocument()
expect(screen.getAllByText('Reasoned')).toHaveLength(2)
})
it('keeps a tool group running while a call in it outlasts a later block', () => {
const message: UIMessage = {
id: 'parallel-1',
role: 'assistant',
parts: [
{ type: 'tool-search_docs', toolCallId: 'docs-1', state: 'input-available', input: {} },
{ type: 'text', text: 'Checking your schema' },
],
}
render()
expect(screen.getByRole('button', { name: 'Searching docs...' })).toBeInTheDocument()
})
it('updates text when the SDK mutates the first streamed part', () => {
const text = { type: 'text' as const, text: 'First token', state: 'streaming' as const }
const message: UIMessage = { id: 'text-1', role: 'assistant', parts: [text] }
const { rerender } = render()
text.text = 'First token and the rest of the response'
rerender()
expect(screen.getByText(text.text)).toBeInTheDocument()
})
it('updates a tool when its initial input-streaming part is mutated to a completed result', () => {
const tool = {
type: 'tool-execute_sql' as const,
toolCallId: 'query-1',
state: 'input-streaming' as const,
}
const message: UIMessage = { id: 'tool-1', role: 'assistant', parts: [tool] }
const { rerender } = render()
expect(screen.getByText('Writing SQL...')).toBeInTheDocument()
Object.assign(tool, {
state: 'output-available',
input: { sql: 'select 1' },
output: [{ value: 1 }],
})
rerender()
expect(screen.queryByText('Writing SQL...')).not.toBeInTheDocument()
expect(screen.getByText('[{"value":1}]')).toBeInTheDocument()
})
})