diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 9b020c6f0..6d0264915 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -1190,7 +1190,10 @@ Assistant chat must not render Pulse-authored explore pre-pass cards or internal workflow-state cards as assistant output. The user-facing stream is model text, model thinking where supported, model-selected tool calls, governed approval requests, and model questions; internal runtime telemetry stays out of -the chat transcript. +the chat transcript. The browser runtime may keep the latest `workflow_state` +message on the in-flight assistant turn only as drawer status text while waiting +for model content, so provider/session progress is visible without turning +runtime telemetry into transcript content. Cold-start Assistant chat session creation is also stream-owned. Ordinary first messages may call `/api/ai/chat` without a `session_id`; `chat.Service.ExecuteStream` must create or resolve the durable session before provider execution and emit a diff --git a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx index 67e63c8e5..6fd6205a6 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx @@ -2134,6 +2134,32 @@ describe('AIChat', () => { expect(screen.queryByText('Generating response...')).not.toBeInTheDocument(); }); + it('shows workflow progress while the assistant turn waits for the first token', () => { + mockChat.isLoading.mockReturnValue(true); + mockChat.messages.mockReturnValue([ + { + id: 'msg-1', + role: 'assistant' as const, + content: '', + timestamp: new Date(), + isStreaming: true, + streamEvents: [], + workflowStatus: { + phase: 'plan', + message: 'Planning governed action and safety checks before execution.', + state: 'READING', + tool: 'pulse_exec', + }, + }, + ]); + renderChat(); + expect( + screen.getByText('Planning governed action and safety checks before execution.'), + ).toBeInTheDocument(); + expect(screen.queryByText('Thinking...')).not.toBeInTheDocument(); + expect(screen.queryByText('Generating response...')).not.toBeInTheDocument(); + }); + it('shows tool status when assistant has pending tools', () => { mockChat.isLoading.mockReturnValue(true); mockChat.messages.mockReturnValue([ diff --git a/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts b/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts index 0cfbbfa02..b353974c1 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts +++ b/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts @@ -661,6 +661,12 @@ describe('useChat', () => { const assistant = chat.messages().find((m) => m.role === 'assistant')!; expect(assistant.content).toBe(''); expect(assistant.streamEvents).toEqual([]); + expect(assistant.workflowStatus).toEqual({ + phase: 'plan', + message: 'Planning governed action and safety checks before execution.', + state: 'READING', + tool: 'pulse_exec', + }); dispose(); }); diff --git a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts index df02f0ff0..3898bad3c 100644 --- a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts +++ b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts @@ -17,6 +17,7 @@ import type { PendingApproval, PendingQuestion, PendingTool, + WorkflowStatus, } from '../types'; const generateId = () => Math.random().toString(36).substring(2, 9); @@ -182,6 +183,24 @@ export function useChat(options: UseChatOptions = {}) { return ''; }; + const extractWorkflowStatus = (data: unknown): WorkflowStatus | null => { + if (!data || typeof data !== 'object') return null; + const record = data as Record; + const message = typeof record.message === 'string' ? record.message.trim() : ''; + if (!message) return null; + + const phase = typeof record.phase === 'string' ? record.phase.trim() : ''; + const state = typeof record.state === 'string' ? record.state.trim() : ''; + const tool = typeof record.tool === 'string' ? record.tool.trim() : ''; + + return { + message, + phase: phase || undefined, + state: state || undefined, + tool: tool || undefined, + }; + }; + const extractSessionId = (data: unknown): string => { if (!data || typeof data !== 'object') return ''; const record = data as Record; @@ -239,6 +258,11 @@ export function useChat(options: UseChatOptions = {}) { }; } + case 'workflow_state': { + const workflowStatus = extractWorkflowStatus(event.data); + return workflowStatus ? { ...msg, workflowStatus } : msg; + } + case 'tool_start': { const data = (event.data || {}) as { id?: string; @@ -494,9 +518,9 @@ export function useChat(options: UseChatOptions = {}) { case 'done': { const tokens = extractTokens(event.data); if (tokens && (tokens.input > 0 || tokens.output > 0)) { - return { ...msg, isStreaming: false, pendingTools: [], tokens }; + return { ...msg, isStreaming: false, pendingTools: [], tokens, workflowStatus: undefined }; } - return { ...msg, isStreaming: false, pendingTools: [] }; + return { ...msg, isStreaming: false, pendingTools: [], workflowStatus: undefined }; } case 'error': { @@ -507,6 +531,7 @@ export function useChat(options: UseChatOptions = {}) { ...msg, isStreaming: false, pendingTools: [], + workflowStatus: undefined, error: errorMsg || 'Request failed', }; } diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index 0e142df6c..a1ed5fb52 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -603,6 +603,11 @@ export const AIChat: Component = (props) => { return { type: 'tool', text: `Running ${toolName}...` }; } + const workflowMessage = lastMessage.workflowStatus?.message.trim(); + if (lastMessage.isStreaming && !lastMessage.content.trim() && workflowMessage) { + return { type: 'thinking', text: workflowMessage }; + } + const isWaitingForFirstToken = lastMessage.isStreaming && !lastMessage.content.trim() && diff --git a/frontend-modern/src/components/AI/Chat/types.ts b/frontend-modern/src/components/AI/Chat/types.ts index 04636bb6f..e0079794d 100644 --- a/frontend-modern/src/components/AI/Chat/types.ts +++ b/frontend-modern/src/components/AI/Chat/types.ts @@ -102,6 +102,13 @@ export interface StreamDisplayEvent { question?: PendingQuestion; // For question events } +export interface WorkflowStatus { + phase?: string; + message: string; + state?: string; + tool?: string; +} + export interface ChatMessage { id: string; role: 'user' | 'assistant'; @@ -118,6 +125,7 @@ export interface ChatMessage { tokens?: { input: number; output: number }; toolCalls?: ToolExecution[]; isStreaming?: boolean; + workflowStatus?: WorkflowStatus; pendingTools?: PendingTool[]; pendingApprovals?: PendingApproval[]; pendingQuestions?: PendingQuestion[];