diff --git a/app/src/components/flows/WorkflowCopilotPanel.test.tsx b/app/src/components/flows/WorkflowCopilotPanel.test.tsx index 2af5ab1cc..6c9322c83 100644 --- a/app/src/components/flows/WorkflowCopilotPanel.test.tsx +++ b/app/src/components/flows/WorkflowCopilotPanel.test.tsx @@ -7,10 +7,20 @@ import WorkflowCopilotPanel from './WorkflowCopilotPanel'; vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) })); +interface MockMessage { + id: string; + content: string; + sender: 'user' | 'agent'; + extraMetadata?: { isInterim?: boolean }; +} + const hookState = vi.hoisted(() => ({ sending: false, proposal: null as WorkflowProposal | null, - messages: [] as Array<{ id: string; content: string; sender: 'user' | 'agent' }>, + // Panel renders `displayMessages` (already interim-filtered upstream by + // `useWorkflowBuilderChat`) — kept separate from `messages` in these tests + // so a mismatch between the two proves the panel is reading the right field. + displayMessages: [] as MockMessage[], toolTimeline: [] as ToolTimelineEntry[], liveResponse: '', error: null as string | null, @@ -41,7 +51,7 @@ describe('WorkflowCopilotPanel', () => { beforeEach(() => { hookState.sending = false; hookState.proposal = null; - hookState.messages = []; + hookState.displayMessages = []; hookState.toolTimeline = []; hookState.liveResponse = ''; hookState.error = null; @@ -136,7 +146,7 @@ describe('WorkflowCopilotPanel', () => { }); it('renders the conversation transcript (user + agent turns)', () => { - hookState.messages = [ + hookState.displayMessages = [ { id: 'm1', content: 'add a Slack step', sender: 'user' }, { id: 'm2', content: 'Done — proposed a Slack notification.', sender: 'agent' }, ]; @@ -157,6 +167,41 @@ describe('WorkflowCopilotPanel', () => { expect(screen.queryByTestId('workflow-copilot-empty')).not.toBeInTheDocument(); }); + it('does not render an isInterim agent message as a bubble, only the terminal one', () => { + // The panel only ever renders `displayMessages` — the same filtered set + // `useWorkflowBuilderChat` computes from the raw transcript (isInterim + // agent messages dropped since that narration already streams live via + // the tool timeline / live text). Mirror that filter here so this test + // documents (and would catch a regression in) the composition: an + // isInterim message must never reach the panel as a bubble, while the + // terminal (non-interim) answer still does. + const raw: MockMessage[] = [ + { id: 'm1', content: 'build me a Slack digest', sender: 'user' }, + { + id: 'm2', + content: 'Let me check your calendar first.', + sender: 'agent', + extraMetadata: { isInterim: true }, + }, + { id: 'm3', content: 'Done — proposed a Slack notification.', sender: 'agent' }, + ]; + hookState.displayMessages = raw.filter(m => m.sender === 'user' || !m.extraMetadata?.isInterim); + + render( + + ); + expect(screen.queryByText('Let me check your calendar first.')).not.toBeInTheDocument(); + expect(screen.getByTestId('workflow-copilot-agent')).toHaveTextContent( + 'Done — proposed a Slack notification.' + ); + }); + it('renders the shared tool timeline + streaming reply during a builder turn', () => { hookState.sending = true; hookState.toolTimeline = [ diff --git a/app/src/components/flows/WorkflowCopilotPanel.tsx b/app/src/components/flows/WorkflowCopilotPanel.tsx index 4075ba834..1bb1ad490 100644 --- a/app/src/components/flows/WorkflowCopilotPanel.tsx +++ b/app/src/components/flows/WorkflowCopilotPanel.tsx @@ -106,7 +106,7 @@ export default function WorkflowCopilotPanel({ threadId, sending, proposal, - messages, + displayMessages, toolTimeline, liveResponse, error, @@ -217,7 +217,7 @@ export default function WorkflowCopilotPanel({ // `scrollTo` is optional-chained: jsdom (tests) doesn't implement it. useEffect(() => { scrollRef.current?.scrollTo?.({ top: scrollRef.current.scrollHeight }); - }, [messages, sending, proposal, toolTimeline, liveResponse]); + }, [displayMessages, sending, proposal, toolTimeline, liveResponse]); const submit = useCallback( async (raw?: string) => { @@ -267,7 +267,7 @@ export default function WorkflowCopilotPanel({ const hasTimeline = toolTimeline.length > 0; const hasLiveText = liveResponse.trim().length > 0; const isEmpty = - messages.length === 0 && !proposal && !sending && !error && !hasTimeline && !hasLiveText; + displayMessages.length === 0 && !proposal && !sending && !error && !hasTimeline && !hasLiveText; return (