diff --git a/frontend-modern/src/api/__tests__/aiChat.test.ts b/frontend-modern/src/api/__tests__/aiChat.test.ts index 76f709c83..abc7e9390 100644 --- a/frontend-modern/src/api/__tests__/aiChat.test.ts +++ b/frontend-modern/src/api/__tests__/aiChat.test.ts @@ -323,6 +323,67 @@ describe('AIChatAPI', () => { }); }); + it('runs the status-boundary dev stream fixture without opening a provider request', async () => { + const onEvent = vi.fn(); + + await AIChatAPI.chat( + '/fixture status-boundary', + undefined, + 'openrouter:qwen/qwen3.7-plus', + onEvent, + ); + + expect(apiFetchMock).not.toHaveBeenCalled(); + expect(onEvent.mock.calls.map(([event]) => event.type)).toEqual([ + 'session', + 'workflow_state', + 'workflow_state', + 'tool_start', + 'tool_end', + 'workflow_state', + 'content', + 'done', + ]); + expect(onEvent.mock.calls[2][0]).toMatchObject({ + type: 'workflow_state', + data: { + phase: 'context', + message: 'Reading current Pulse inventory with pulse_query.', + tool: 'pulse_query', + }, + }); + expect(onEvent.mock.calls[4][0]).toMatchObject({ + type: 'tool_end', + data: { + id: 'fixture-tool-status-boundary', + name: 'pulse_query', + output: expect.stringContaining('"devices":3'), + success: true, + }, + }); + expect(onEvent.mock.calls[5][0]).toMatchObject({ + type: 'workflow_state', + data: { + phase: 'provider_start', + message: 'Sent request to OpenRouter; waiting for the first token.', + model: 'openrouter:qwen/qwen3.7-plus', + }, + }); + expect(onEvent.mock.calls[6][0]).toMatchObject({ + type: 'content', + data: { + text: expect.stringContaining('inventory status row stable'), + }, + }); + expect(onEvent.mock.calls[7][0]).toMatchObject({ + type: 'done', + data: { + session_id: 'dev-fixture-status-boundary', + model: 'openrouter:qwen/qwen3.7-plus', + }, + }); + }); + it('fails unknown dev fixture prompts locally instead of sending them to a provider', async () => { const onEvent = vi.fn(); diff --git a/frontend-modern/src/api/aiChatDevStreamFixture.ts b/frontend-modern/src/api/aiChatDevStreamFixture.ts index 650912b3f..66fa9783e 100644 --- a/frontend-modern/src/api/aiChatDevStreamFixture.ts +++ b/frontend-modern/src/api/aiChatDevStreamFixture.ts @@ -5,6 +5,7 @@ export const AI_CHAT_DEV_STREAM_FIXTURE_PROMPTS = [ '/fixture assistant-stream', '/fixture tool-burst', '/fixture context-group', + '/fixture status-boundary', '/fixture pending-tool', '/fixture provider-retry', '/fixture stream-idle', @@ -372,6 +373,71 @@ const buildContextGroupFixtureEvents = (model?: string): AIChatStreamEvent[] => }, ]; +const buildStatusBoundaryFixtureEvents = (model?: string): AIChatStreamEvent[] => [ + { + type: 'session', + data: { id: 'dev-fixture-status-boundary' }, + }, + { + type: 'workflow_state', + data: { + phase: 'request_start', + message: 'Preparing Pulse context.', + }, + }, + { + type: 'workflow_state', + data: { + phase: 'context', + message: 'Reading current Pulse inventory with pulse_query.', + tool: 'pulse_query', + }, + }, + { + type: 'tool_start', + data: { + id: 'fixture-tool-status-boundary', + name: 'pulse_query', + input: '{"query":"devices"}', + raw_input: 'pulse_query(query="devices")', + }, + }, + { + type: 'tool_end', + data: { + id: 'fixture-tool-status-boundary', + name: 'pulse_query', + input: '{"query":"devices"}', + raw_input: 'pulse_query(query="devices")', + output: '{"devices":3,"source":"fixture"}', + success: true, + }, + }, + { + type: 'workflow_state', + data: { + phase: 'provider_start', + message: 'Sent request to OpenRouter; waiting for the first token.', + model: assistantFixtureModel(model), + }, + }, + { + type: 'content', + data: { + text: 'The status-boundary fixture kept the inventory status row stable before the provider wait row.', + }, + }, + { + type: 'done', + data: { + session_id: 'dev-fixture-status-boundary', + model: assistantFixtureModel(model), + input_tokens: 74, + output_tokens: 21, + }, + }, +]; + const buildPendingToolFixtureEvents = (model?: string): AIChatStreamEvent[] => [ { type: 'session', @@ -717,6 +783,9 @@ const buildFixtureEvents = (prompt: string, model?: string): AIChatStreamEvent[] if (normalized === '/fixture context-group') { return buildContextGroupFixtureEvents(model); } + if (normalized === '/fixture status-boundary') { + return buildStatusBoundaryFixtureEvents(model); + } if (normalized === '/fixture pending-tool') { return buildPendingToolFixtureEvents(model); } diff --git a/frontend-modern/src/components/AI/Chat/MessageItem.tsx b/frontend-modern/src/components/AI/Chat/MessageItem.tsx index 140f1d974..ca129e349 100644 --- a/frontend-modern/src/components/AI/Chat/MessageItem.tsx +++ b/frontend-modern/src/components/AI/Chat/MessageItem.tsx @@ -27,10 +27,6 @@ import { getAssistantAnswerText } from './assistantAnswerText'; import { stripAssistantOutputArtifacts } from './assistantOutputHygiene'; import { formatAssistantWorkflowStatus, isInitialRequestStartStatus } from './activeTurnStatus'; import { groupStreamEventsForDisplay } from './streamEventGrouping'; -import { - latestWorkflowStatus, - normalizeWorkflowStatusSequence, -} from './workflowStatusPresentation'; import type { ChatMessage, ModelRouteRecoveryOption, @@ -383,19 +379,6 @@ export const MessageItem: Component = (props) => { const workflowStatusText = createMemo(() => formatWorkflowStatus(props.message.workflowStatus, true), ); - const workflowStatusSequence = (status?: WorkflowStatus) => - normalizeWorkflowStatusSequence([...(props.message.workflowStatusHistory || []), status]); - const WorkflowStatusText: Component<{ - status?: WorkflowStatus; - includeElapsed?: boolean; - }> = (statusProps) => { - const statuses = createMemo(() => workflowStatusSequence(statusProps.status)); - const displayedStatus = createMemo(() => latestWorkflowStatus(statuses())); - const text = createMemo(() => - formatWorkflowStatus(displayedStatus(), statusProps.includeElapsed), - ); - return <>{text()}; - }; const shouldShowHeaderWorkflowStatus = () => props.message.isStreaming && !isWaitingForFirstToken() && @@ -511,9 +494,7 @@ export const MessageItem: Component = (props) => { aria-live="polite" > - - - + {workflowStatusText()} @@ -549,9 +530,7 @@ export const MessageItem: Component = (props) => { /> Thinking...}> - - - + {workflowStatusText()} @@ -599,10 +578,7 @@ export const MessageItem: Component = (props) => { aria-hidden="true" /> - + {formatWorkflowStatus(evt?.workflowStatus, props.message.isStreaming)} diff --git a/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx index eaede5b56..1f6ad9560 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx @@ -789,6 +789,65 @@ describe('MessageItem', () => { ).toBeInTheDocument(); }); + it('keeps separated transcript workflow rows tied to their own streamed status', () => { + vi.useFakeTimers(); + vi.setSystemTime(2_000); + + const workflowStatusHistory = [ + { + phase: 'context', + message: 'Reading current Pulse inventory with pulse_query.', + tool: 'pulse_query', + startedAt: 1_100, + }, + { + phase: 'provider_start', + message: 'Sent request to OpenRouter; waiting for the first token.', + startedAt: 1_300, + }, + ]; + + render(() => ( + + )); + + expect(screen.getByText('Reading current Pulse inventory.')).toBeInTheDocument(); + expect(screen.getByText('3 devices found')).toBeInTheDocument(); + expect( + screen.getByText('Sent request to OpenRouter; waiting for the first token.'), + ).toBeInTheDocument(); + expect(screen.queryByText(/pulse_query/)).not.toBeInTheDocument(); + }); + it('hides stale workflow progress after visible content starts', () => { render(() => (