mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 22:12:23 +00:00
Surface Assistant workflow progress status
This commit is contained in:
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user