Keep Assistant status rows event-scoped

This commit is contained in:
rcourtman
2026-06-07 12:26:50 +01:00
parent b216e38304
commit 93bf32dfcf
4 changed files with 192 additions and 27 deletions
@@ -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();
@@ -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);
}
@@ -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<MessageItemProps> = (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<MessageItemProps> = (props) => {
aria-live="polite"
>
<span class="h-1.5 w-1.5 shrink-0 rounded-full bg-blue-500 animate-pulse" />
<span class="truncate">
<WorkflowStatusText status={props.message.workflowStatus} includeElapsed />
</span>
<span class="truncate">{workflowStatusText()}</span>
</span>
</Show>
<Show when={canCopy()}>
@@ -549,9 +530,7 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
/>
</span>
<Show when={workflowStatusText()} fallback={<span>Thinking...</span>}>
<span>
<WorkflowStatusText status={props.message.workflowStatus} includeElapsed />
</span>
<span>{workflowStatusText()}</span>
</Show>
</div>
</Show>
@@ -599,10 +578,7 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
aria-hidden="true"
/>
<span class="min-w-0 truncate">
<WorkflowStatusText
status={evt?.workflowStatus}
includeElapsed={props.message.isStreaming}
/>
{formatWorkflowStatus(evt?.workflowStatus, props.message.isStreaming)}
</span>
</div>
</Match>
@@ -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(() => (
<MessageItem
message={makeMessage({
role: 'assistant',
content: '',
isStreaming: true,
pendingTools: [],
workflowStatusHistory,
streamEvents: [
{
type: 'workflow_status',
workflowStatus: workflowStatusHistory[0],
},
{
type: 'tool',
tool: {
name: 'pulse_query',
input: '{}',
output: '3 devices found',
success: true,
},
},
{
type: 'workflow_status',
workflowStatus: workflowStatusHistory[1],
},
],
workflowStatus: workflowStatusHistory[1],
})}
{...makeHandlers()}
/>
));
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(() => (
<MessageItem