mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-24 20:22:53 +00:00
Show Assistant live workflow activity
This commit is contained in:
@@ -1523,6 +1523,24 @@ attempts. The frontend active-turn footer renders that same typed workflow state
|
||||
as compact attempt/backoff progress, so the user sees Pulse moving through a
|
||||
retry instead of staring at an obsolete provider-wait message.
|
||||
|
||||
Assistant workflow progress is also a live typed activity row while a turn is
|
||||
in flight, not only hidden footer state. The referenced OpenCode source at
|
||||
fetched `dev` commit `7ae856a9e97130f664f6f11fa5871a2795de9902` stores
|
||||
session status separately from message parts in
|
||||
`packages/opencode/src/cli/cmd/tui/context/sync.tsx` (`session_status`,
|
||||
`session.status` event handling) and renders a session from live messages,
|
||||
permissions, questions, and running tool parts in
|
||||
`packages/opencode/src/cli/cmd/tui/routes/session/index.tsx` (`Session`,
|
||||
`messages`, `permissions`, `questions`, foreground `ToolPart` selection, and
|
||||
`session.status` handling). Pulse adapts that source pattern with a transient
|
||||
frontend `workflow_status` display event: each incoming backend
|
||||
`workflow_state` replaces the prior workflow row, the active footer reads the
|
||||
same typed status, and visible assistant content, reasoning, tool,
|
||||
approval/question, terminal `done`, and terminal `error` events clear that row.
|
||||
The transcript therefore shows current motion while the provider is starting,
|
||||
retrying, or reasoning, but completed answers do not retain stale
|
||||
internal-progress prose.
|
||||
|
||||
Primary nav moved to governed platform/runtime destinations on 2026-05-16 and
|
||||
was clarified on 2026-05-25 through `frontend-modern/src/App.tsx` and
|
||||
`frontend-modern/src/AppLayout.tsx`: the top of the app may expose canonical
|
||||
|
||||
@@ -24,6 +24,7 @@ import { ApprovalCard } from './ApprovalCard';
|
||||
import { QuestionCard } from './QuestionCard';
|
||||
import { ThinkingBlock } from './ThinkingBlock';
|
||||
import { stripAssistantOutputArtifacts } from './assistantOutputHygiene';
|
||||
import { formatAssistantWorkflowStatus } from './activeTurnStatus';
|
||||
import { groupStreamEventsForDisplay } from './streamEventGrouping';
|
||||
import type {
|
||||
ChatMessage,
|
||||
@@ -111,6 +112,8 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
|
||||
switch (evt.type) {
|
||||
case 'thinking':
|
||||
return !!evt.thinking?.trim();
|
||||
case 'workflow_status':
|
||||
return !!formatAssistantWorkflowStatus(evt.workflowStatus);
|
||||
case 'content':
|
||||
return !!stripAssistantOutputArtifacts(evt.content || '').text;
|
||||
case 'tool':
|
||||
@@ -185,10 +188,8 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
|
||||
onCleanup(() => window.clearInterval(interval));
|
||||
});
|
||||
const formatWorkflowStatus = (status?: WorkflowStatus, includeElapsed = false) => {
|
||||
const message = status?.message?.trim();
|
||||
const message = formatAssistantWorkflowStatus(status);
|
||||
if (!message) return '';
|
||||
const tool = status?.tool?.trim();
|
||||
const toolSuffix = tool && !message.includes(tool) ? ` · ${formatIdentifierLabel(tool)}` : '';
|
||||
let elapsedSuffix = '';
|
||||
if (includeElapsed && status?.startedAt) {
|
||||
const elapsedSeconds = Math.max(0, Math.floor((statusNow() - status.startedAt) / 1000));
|
||||
@@ -196,7 +197,7 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
|
||||
elapsedSuffix = ` (${elapsedSeconds}s)`;
|
||||
}
|
||||
}
|
||||
return `${message}${toolSuffix}${elapsedSuffix}`;
|
||||
return `${message}${elapsedSuffix}`;
|
||||
};
|
||||
const workflowStatusText = createMemo(() =>
|
||||
formatWorkflowStatus(props.message.workflowStatus, true),
|
||||
@@ -377,6 +378,31 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
|
||||
/>
|
||||
</Match>
|
||||
|
||||
<Match
|
||||
when={
|
||||
evt.type === 'workflow_status' &&
|
||||
formatAssistantWorkflowStatus(evt.workflowStatus)
|
||||
}
|
||||
>
|
||||
<div
|
||||
class="my-1 inline-flex max-w-full items-center gap-2 rounded-md border border-blue-200 bg-blue-50 px-2.5 py-1.5 text-xs font-medium text-blue-700 dark:border-blue-900/60 dark:bg-blue-950/30 dark:text-blue-200"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
title={formatWorkflowStatus(
|
||||
evt.workflowStatus,
|
||||
props.message.isStreaming,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
class="h-1.5 w-1.5 shrink-0 rounded-full bg-blue-500 animate-pulse"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="min-w-0 truncate">
|
||||
{formatWorkflowStatus(evt.workflowStatus, props.message.isStreaming)}
|
||||
</span>
|
||||
</div>
|
||||
</Match>
|
||||
|
||||
<Match when={evt.type === 'pending_tool' && evt.pendingTool}>
|
||||
<PendingToolBlock tool={evt.pendingTool!} />
|
||||
</Match>
|
||||
@@ -409,8 +435,7 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
|
||||
when={isProviderFallbackEvent(evt)}
|
||||
fallback={
|
||||
<>
|
||||
<span class="shrink-0">Switched to</span>
|
||||
{' '}
|
||||
<span class="shrink-0">Switched to</span>{' '}
|
||||
<span class="truncate font-medium text-base-content">
|
||||
{modelRouteLabel(evt.model)}
|
||||
</span>
|
||||
|
||||
@@ -204,10 +204,7 @@ describe('MessageItem', () => {
|
||||
});
|
||||
expect(retryViaOpenRouter).toHaveTextContent('Retry via OpenRouter');
|
||||
fireEvent.click(retryViaOpenRouter);
|
||||
expect(onUseModelRoute).toHaveBeenCalledWith(
|
||||
'openrouter:deepseek/deepseek-v4-pro',
|
||||
'msg-1',
|
||||
);
|
||||
expect(onUseModelRoute).toHaveBeenCalledWith('openrouter:deepseek/deepseek-v4-pro', 'msg-1');
|
||||
|
||||
const changeModel = screen.getByRole('button', { name: /change model/i });
|
||||
fireEvent.click(changeModel);
|
||||
@@ -584,7 +581,47 @@ describe('MessageItem', () => {
|
||||
));
|
||||
|
||||
expect(
|
||||
screen.getByText('Planning governed action and safety checks before execution. · pulse exec'),
|
||||
screen.getByText('Planning governed action and safety checks before execution. · exec'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('Thinking...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows live workflow progress as a transcript activity row', () => {
|
||||
render(() => (
|
||||
<MessageItem
|
||||
message={makeMessage({
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
isStreaming: true,
|
||||
pendingTools: [],
|
||||
streamEvents: [
|
||||
{
|
||||
type: 'workflow_status',
|
||||
workflowStatus: {
|
||||
phase: 'provider_retry',
|
||||
message: 'Provider connection failed before any output; retrying.',
|
||||
attempt: 2,
|
||||
maxAttempts: 3,
|
||||
retryAfterMs: 1200,
|
||||
},
|
||||
},
|
||||
],
|
||||
workflowStatus: {
|
||||
phase: 'provider_retry',
|
||||
message: 'Provider connection failed before any output; retrying.',
|
||||
attempt: 2,
|
||||
maxAttempts: 3,
|
||||
retryAfterMs: 1200,
|
||||
},
|
||||
})}
|
||||
{...makeHandlers()}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Provider connection failed before any output; retrying. · attempt 2/3 · retrying in 1.2s',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('Thinking...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -576,6 +576,47 @@ describe('getAssistantActiveTurnStatus', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces the latest workflow activity row when it replaces older hidden status', () => {
|
||||
expect(
|
||||
getAssistantActiveTurnStatus(
|
||||
[
|
||||
assistantMessage({
|
||||
isStreaming: true,
|
||||
streamEvents: [
|
||||
{
|
||||
type: 'tool',
|
||||
toolId: 'tool-1',
|
||||
tool: {
|
||||
name: 'pulse_alerts',
|
||||
input: '{}',
|
||||
output: '11 active alerts',
|
||||
success: true,
|
||||
},
|
||||
startedAt: 1000,
|
||||
updatedAt: 1100,
|
||||
},
|
||||
{
|
||||
type: 'workflow_status',
|
||||
workflowStatus: {
|
||||
phase: 'model_thinking',
|
||||
message: 'Model is reasoning before responding.',
|
||||
startedAt: 2000,
|
||||
},
|
||||
startedAt: 2000,
|
||||
updatedAt: 2000,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
true,
|
||||
),
|
||||
).toEqual({
|
||||
type: 'thinking',
|
||||
text: 'Model is reasoning before responding.',
|
||||
startedAt: 2000,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores stale workflow status on a completed assistant turn', () => {
|
||||
expect(
|
||||
getAssistantActiveTurnStatus(
|
||||
|
||||
@@ -1113,7 +1113,17 @@ describe('useChat', () => {
|
||||
|
||||
const assistant = chat.messages().find((m) => m.role === 'assistant')!;
|
||||
expect(assistant.content).toBe('');
|
||||
expect(assistant.streamEvents).toEqual([]);
|
||||
expect(assistant.streamEvents).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'workflow_status',
|
||||
workflowStatus: expect.objectContaining({
|
||||
phase: 'plan',
|
||||
message: 'Planning governed action and safety checks before execution.',
|
||||
state: 'READING',
|
||||
tool: 'pulse_exec',
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
expect(assistant.workflowStatus).toEqual(
|
||||
expect.objectContaining({
|
||||
phase: 'plan',
|
||||
@@ -1194,6 +1204,17 @@ describe('useChat', () => {
|
||||
retryAfterMs: 200,
|
||||
}),
|
||||
);
|
||||
expect(assistant.streamEvents).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'workflow_status',
|
||||
workflowStatus: expect.objectContaining({
|
||||
phase: 'provider_retry',
|
||||
attempt: 2,
|
||||
maxAttempts: 2,
|
||||
retryAfterMs: 200,
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
dispose();
|
||||
});
|
||||
|
||||
@@ -1243,7 +1264,14 @@ describe('useChat', () => {
|
||||
},
|
||||
});
|
||||
let assistant = chat.messages().find((m) => m.role === 'assistant')!;
|
||||
expect(assistant.streamEvents).toEqual([]);
|
||||
expect(assistant.streamEvents).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'workflow_status',
|
||||
workflowStatus: expect.objectContaining({
|
||||
message: 'Reading current Pulse inventory with pulse_query.',
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
expect(assistant.workflowStatus).toEqual(
|
||||
expect.objectContaining({
|
||||
message: 'Reading current Pulse inventory with pulse_query.',
|
||||
@@ -1264,6 +1292,14 @@ describe('useChat', () => {
|
||||
message: 'Built compact inventory context for the model.',
|
||||
}),
|
||||
);
|
||||
expect(assistant.streamEvents).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'workflow_status',
|
||||
workflowStatus: expect.objectContaining({
|
||||
message: 'Built compact inventory context for the model.',
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
||||
fire({
|
||||
type: 'workflow_state',
|
||||
@@ -1278,6 +1314,14 @@ describe('useChat', () => {
|
||||
message: 'Sent request to OpenRouter; waiting for the first token.',
|
||||
}),
|
||||
);
|
||||
expect(assistant.streamEvents).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'workflow_status',
|
||||
workflowStatus: expect.objectContaining({
|
||||
message: 'Sent request to OpenRouter; waiting for the first token.',
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
||||
dispose();
|
||||
});
|
||||
@@ -1301,6 +1345,7 @@ describe('useChat', () => {
|
||||
const assistant = chat.messages().find((m) => m.role === 'assistant')!;
|
||||
expect(assistant.content).toBe('Here is the answer.');
|
||||
expect(assistant.workflowStatus).toBeUndefined();
|
||||
expect(assistant.streamEvents?.map((event) => event.type)).toEqual(['content']);
|
||||
dispose();
|
||||
});
|
||||
|
||||
@@ -1323,10 +1368,11 @@ describe('useChat', () => {
|
||||
const assistant = chat.messages().find((m) => m.role === 'assistant')!;
|
||||
expect(assistant.workflowStatus).toBeUndefined();
|
||||
expect(assistant.pendingTools).toHaveLength(1);
|
||||
expect(assistant.streamEvents?.map((event) => event.type)).toEqual(['pending_tool']);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('keeps late workflow progress live after typed tool evidence without adding transcript rows', async () => {
|
||||
it('keeps late workflow progress live after typed tool evidence as one current activity row', async () => {
|
||||
const { getFireEvent } = setupWithEventCapture();
|
||||
const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 's' }));
|
||||
|
||||
@@ -1366,7 +1412,18 @@ describe('useChat', () => {
|
||||
message: 'Model is reasoning before responding.',
|
||||
}),
|
||||
);
|
||||
expect(assistant.streamEvents?.map((event) => event.type)).toEqual(['tool']);
|
||||
expect(assistant.streamEvents?.map((event) => event.type)).toEqual([
|
||||
'tool',
|
||||
'workflow_status',
|
||||
]);
|
||||
expect(assistant.streamEvents?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
workflowStatus: expect.objectContaining({
|
||||
phase: 'model_thinking',
|
||||
message: 'Model is reasoning before responding.',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(assistant.toolCalls).toHaveLength(1);
|
||||
dispose();
|
||||
});
|
||||
|
||||
@@ -154,6 +154,9 @@ const thinkingStatusText = (event: StreamDisplayEvent): string => {
|
||||
};
|
||||
|
||||
const eventActivityAt = (event: StreamDisplayEvent): number | undefined => {
|
||||
if (event.type === 'workflow_status') {
|
||||
return event.workflowStatus?.startedAt || event.updatedAt || event.startedAt || undefined;
|
||||
}
|
||||
if (event.type === 'pending_tool') {
|
||||
return (
|
||||
latestPendingToolActivity(event.pendingTool) ||
|
||||
@@ -228,6 +231,19 @@ const latestStreamActivityStatus = (
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'workflow_status': {
|
||||
const text = formatAssistantWorkflowStatus(event.workflowStatus);
|
||||
if (text) {
|
||||
candidate = {
|
||||
type: event.workflowStatus?.tool ? 'tool' : 'thinking',
|
||||
text,
|
||||
startedAt: event.workflowStatus?.startedAt || event.startedAt,
|
||||
activityAt: eventActivityAt(event),
|
||||
order: index,
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'content': {
|
||||
if (event.content?.trim()) {
|
||||
candidate = {
|
||||
|
||||
@@ -166,7 +166,10 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
if (!events) return events;
|
||||
return events.filter(
|
||||
(event) =>
|
||||
event.type !== 'pending_tool' && event.type !== 'approval' && event.type !== 'question',
|
||||
event.type !== 'pending_tool' &&
|
||||
event.type !== 'approval' &&
|
||||
event.type !== 'question' &&
|
||||
event.type !== 'workflow_status',
|
||||
);
|
||||
};
|
||||
|
||||
@@ -310,6 +313,31 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
};
|
||||
};
|
||||
|
||||
const streamEventsWithoutWorkflowStatus = (
|
||||
events: StreamDisplayEvent[] | undefined,
|
||||
): StreamDisplayEvent[] | undefined =>
|
||||
events?.filter((event) => event.type !== 'workflow_status');
|
||||
|
||||
const withClearedWorkflowStatusEvent = (msg: ChatMessage): ChatMessage => {
|
||||
const streamEvents = streamEventsWithoutWorkflowStatus(msg.streamEvents);
|
||||
if (streamEvents === msg.streamEvents) return msg;
|
||||
return { ...msg, streamEvents };
|
||||
};
|
||||
|
||||
const withWorkflowStatusEvent = (msg: ChatMessage, workflowStatus: WorkflowStatus): ChatMessage =>
|
||||
addStreamEvent(
|
||||
{
|
||||
...msg,
|
||||
streamEvents: streamEventsWithoutWorkflowStatus(msg.streamEvents) || [],
|
||||
},
|
||||
{
|
||||
type: 'workflow_status',
|
||||
workflowStatus,
|
||||
startedAt: workflowStatus.startedAt,
|
||||
updatedAt: workflowStatus.startedAt,
|
||||
},
|
||||
);
|
||||
|
||||
const normalizePendingToolStatus = (phase?: string): PendingTool['status'] => {
|
||||
const normalized = (phase || 'running').trim().toLowerCase();
|
||||
if (normalized === 'pending') return 'pending';
|
||||
@@ -426,7 +454,7 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
return {
|
||||
...msg,
|
||||
streamEvents: replacePendingToolStreamEvents(
|
||||
msg.streamEvents || [],
|
||||
streamEventsWithoutWorkflowStatus(msg.streamEvents) || [],
|
||||
resolvedTool,
|
||||
matchesTool,
|
||||
now,
|
||||
@@ -505,7 +533,12 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
return {
|
||||
...msg,
|
||||
streamEvents: resolvedTool
|
||||
? replacePendingToolStreamEvents(msg.streamEvents || [], resolvedTool, matchesTool, now)
|
||||
? replacePendingToolStreamEvents(
|
||||
streamEventsWithoutWorkflowStatus(msg.streamEvents) || [],
|
||||
resolvedTool,
|
||||
matchesTool,
|
||||
now,
|
||||
)
|
||||
: msg.streamEvents,
|
||||
workflowStatus: undefined,
|
||||
pendingTools: updatedPendingTools,
|
||||
@@ -859,6 +892,13 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
);
|
||||
}
|
||||
if (workflowStatus) {
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) => {
|
||||
if (msg.id !== assistantId) return msg;
|
||||
if (msg.isStreaming === false) return msg;
|
||||
return withWorkflowStatusEvent(msg, workflowStatus);
|
||||
}),
|
||||
);
|
||||
setAssistantWorkflowStatus(assistantId, requestId, workflowStatus);
|
||||
}
|
||||
return;
|
||||
@@ -892,9 +932,10 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
)
|
||||
: msg;
|
||||
if (!visible.text) return baseMsg;
|
||||
const clearedMsg = withClearedWorkflowStatusEvent(baseMsg);
|
||||
// Add to streamEvents for chronological display
|
||||
const now = Date.now();
|
||||
const updated = addStreamEvent(baseMsg, {
|
||||
const updated = addStreamEvent(clearedMsg, {
|
||||
type: 'content',
|
||||
content: visible.text,
|
||||
startedAt: now,
|
||||
@@ -902,7 +943,7 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
});
|
||||
return {
|
||||
...updated,
|
||||
content: appendMessageContent(baseMsg, visible.text),
|
||||
content: appendMessageContent(clearedMsg, visible.text),
|
||||
workflowStatus: undefined,
|
||||
};
|
||||
}
|
||||
@@ -911,7 +952,8 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
const thinking = extractText(event.data);
|
||||
if (!thinking) return msg;
|
||||
const now = Date.now();
|
||||
const updated = addStreamEvent(msg, {
|
||||
const clearedMsg = withClearedWorkflowStatusEvent(msg);
|
||||
const updated = addStreamEvent(clearedMsg, {
|
||||
type: 'thinking',
|
||||
thinking,
|
||||
startedAt: now,
|
||||
@@ -976,7 +1018,7 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
pendingTools: (msg.pendingTools || []).filter(
|
||||
(tool) => !matchesTool(tool, tool.id),
|
||||
),
|
||||
streamEvents: (msg.streamEvents || []).filter(
|
||||
streamEvents: (streamEventsWithoutWorkflowStatus(msg.streamEvents) || []).filter(
|
||||
(streamEvent) =>
|
||||
streamEvent.type !== 'pending_tool' ||
|
||||
!matchesTool(streamEvent.pendingTool, streamEvent.toolId),
|
||||
@@ -995,7 +1037,7 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
success: boolean;
|
||||
};
|
||||
const pendingTools = msg.pendingTools || [];
|
||||
const events = msg.streamEvents || [];
|
||||
const events = streamEventsWithoutWorkflowStatus(msg.streamEvents) || [];
|
||||
|
||||
const normalizedEndName = normalizeChatToolName(data.name || '');
|
||||
|
||||
@@ -1187,7 +1229,10 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
}
|
||||
|
||||
// Add to streamEvents for chronological display
|
||||
const updated = addStreamEvent(msg, { type: 'approval', approval });
|
||||
const updated = addStreamEvent(withClearedWorkflowStatusEvent(msg), {
|
||||
type: 'approval',
|
||||
approval,
|
||||
});
|
||||
|
||||
return {
|
||||
...updated,
|
||||
@@ -1223,7 +1268,10 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
};
|
||||
|
||||
// Add to streamEvents for chronological display
|
||||
const updated = addStreamEvent(msg, { type: 'question', question: pendingQuestion });
|
||||
const updated = addStreamEvent(withClearedWorkflowStatusEvent(msg), {
|
||||
type: 'question',
|
||||
question: pendingQuestion,
|
||||
});
|
||||
|
||||
return {
|
||||
...updated,
|
||||
@@ -1234,6 +1282,7 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
|
||||
case 'done': {
|
||||
const completedAt = new Date();
|
||||
const terminalMsg = withClearedWorkflowStatusEvent(msg);
|
||||
const pendingText = suppressedRawContentMessageIds.has(assistantId)
|
||||
? ''
|
||||
: flushPendingAssistantOutputText(outputArtifactStateFor(assistantId));
|
||||
@@ -1241,15 +1290,15 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
clearOutputArtifactState(assistantId);
|
||||
const flushedMsg = pendingText
|
||||
? {
|
||||
...addStreamEvent(msg, {
|
||||
...addStreamEvent(terminalMsg, {
|
||||
type: 'content',
|
||||
content: pendingText,
|
||||
startedAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}),
|
||||
content: appendMessageContent(msg, pendingText),
|
||||
content: appendMessageContent(terminalMsg, pendingText),
|
||||
}
|
||||
: msg;
|
||||
: terminalMsg;
|
||||
const tokens = extractTokens(event.data);
|
||||
const completedModel = extractCompletedModel(event.data);
|
||||
if (tokens && (tokens.input > 0 || tokens.output > 0)) {
|
||||
@@ -1284,16 +1333,19 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
case 'error': {
|
||||
clearSuppressedOutputBoundary(assistantId);
|
||||
const errorMsg = extractErrorMessage(event.data);
|
||||
const terminalMsg = withClearedWorkflowStatusEvent(msg);
|
||||
// Keep any content streamed before the failure; surface the error
|
||||
// as a distinct, recoverable block rather than overwriting the answer.
|
||||
return {
|
||||
...msg,
|
||||
...terminalMsg,
|
||||
isStreaming: false,
|
||||
completedAt: new Date(),
|
||||
pendingTools: [],
|
||||
pendingApprovals: [],
|
||||
pendingQuestions: [],
|
||||
streamEvents: streamEventsWithoutUnresolvedInteractiveRows(msg.streamEvents),
|
||||
streamEvents: streamEventsWithoutUnresolvedInteractiveRows(
|
||||
terminalMsg.streamEvents,
|
||||
),
|
||||
workflowStatus: undefined,
|
||||
error: errorMsg || 'Request failed',
|
||||
};
|
||||
|
||||
@@ -96,6 +96,7 @@ export interface PendingQuestion {
|
||||
// Unified event for chronological display
|
||||
export type StreamEventType =
|
||||
| 'thinking'
|
||||
| 'workflow_status'
|
||||
| 'tool'
|
||||
| 'content'
|
||||
| 'pending_tool'
|
||||
@@ -106,6 +107,7 @@ export type StreamEventType =
|
||||
export interface StreamDisplayEvent {
|
||||
type: StreamEventType;
|
||||
thinking?: string;
|
||||
workflowStatus?: WorkflowStatus;
|
||||
startedAt?: number;
|
||||
updatedAt?: number;
|
||||
tool?: ToolExecution;
|
||||
|
||||
Reference in New Issue
Block a user