mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
Keep Assistant status rows event-scoped
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user