Surface Assistant workflow progress status

This commit is contained in:
rcourtman
2026-06-05 12:17:35 +01:00
parent 86de23cc78
commit 1bf0ffde55
6 changed files with 76 additions and 3 deletions
@@ -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
@@ -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([
@@ -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();
});
@@ -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<string, unknown>;
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<string, unknown>;
@@ -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',
};
}
@@ -603,6 +603,11 @@ export const AIChat: Component<AIChatProps> = (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() &&
@@ -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[];