mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 22:12:23 +00:00
Timestamp Assistant progress stream events
This commit is contained in:
@@ -281,10 +281,10 @@ runtime cost control, and shared AI transport surfaces.
|
||||
`1399323b78a04229d9bfe00c7436d7f41770fda8` applies each typed event to the
|
||||
active assistant message in
|
||||
`packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx`; Pulse adapts that
|
||||
precedence by letting typed content, tool, approval, and question evidence
|
||||
own the visible row once it exists, while later neutral workflow states such
|
||||
as provider reasoning still replace the live footer heartbeat instead of
|
||||
being hidden behind generic "generating" copy.
|
||||
precedence by letting typed content, model-switch, tool, approval, and
|
||||
question evidence own the visible row once it exists, while later neutral
|
||||
workflow states such as provider reasoning still replace the live footer
|
||||
heartbeat instead of being hidden behind generic "generating" copy.
|
||||
The referenced OpenCode source at commit
|
||||
`9ed17da55ab1f7360cc0e01075f763e27fa899e9` renders active assistant work
|
||||
as session-owned parts in
|
||||
@@ -309,8 +309,8 @@ runtime cost control, and shared AI transport surfaces.
|
||||
render workflow/tool steps only after a batch has already finished.
|
||||
`frontend-modern/src/api/aiChat.ts` owns the Assistant predicate: token
|
||||
content and hidden reasoning may continue to opt out of those checkpoints so
|
||||
answer streaming remains fast, while session/workflow/tool/approval/question
|
||||
events remain user-visible progress checkpoints.
|
||||
answer streaming remains fast, while session, workflow, model-switch, tool,
|
||||
approval, and question events remain user-visible progress checkpoints.
|
||||
Completed Assistant tool rows follow the same source-anchored display policy:
|
||||
the referenced OpenCode commit
|
||||
`9ed17da55ab1f7360cc0e01075f763e27fa899e9` keeps ordinary tool activity terse
|
||||
|
||||
@@ -193,6 +193,108 @@ describe('getAssistantActiveTurnStatus', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('lets a newer provider switch replace stale waiting progress', () => {
|
||||
expect(
|
||||
getAssistantActiveTurnStatus(
|
||||
[
|
||||
assistantMessage({
|
||||
workflowStatus: {
|
||||
phase: 'provider_start',
|
||||
message: 'Sent request to OpenRouter; waiting for the first token.',
|
||||
startedAt: 1_000,
|
||||
},
|
||||
streamEvents: [
|
||||
{
|
||||
type: 'model_switch',
|
||||
model: 'openrouter:deepseek/deepseek-v4-pro',
|
||||
startedAt: 2_000,
|
||||
updatedAt: 2_000,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
true,
|
||||
),
|
||||
).toEqual({
|
||||
type: 'thinking',
|
||||
text: 'Switched to DeepSeek: DeepSeek V4 Pro via OpenRouter',
|
||||
startedAt: 2_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('carries approval event timing into the active waiting status', () => {
|
||||
expect(
|
||||
getAssistantActiveTurnStatus(
|
||||
[
|
||||
assistantMessage({
|
||||
streamEvents: [
|
||||
{
|
||||
type: 'content',
|
||||
content: 'I found the target service.',
|
||||
startedAt: 1_000,
|
||||
updatedAt: 1_200,
|
||||
},
|
||||
{
|
||||
type: 'approval',
|
||||
startedAt: 2_000,
|
||||
updatedAt: 2_000,
|
||||
approval: {
|
||||
command: 'systemctl restart nginx',
|
||||
toolId: 'tool-1',
|
||||
toolName: 'pulse_control',
|
||||
runOnHost: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
true,
|
||||
),
|
||||
).toEqual({
|
||||
type: 'thinking',
|
||||
text: 'Waiting for approval',
|
||||
startedAt: 2_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('carries question event timing into the active waiting status', () => {
|
||||
expect(
|
||||
getAssistantActiveTurnStatus(
|
||||
[
|
||||
assistantMessage({
|
||||
streamEvents: [
|
||||
{
|
||||
type: 'approval',
|
||||
startedAt: 1_000,
|
||||
updatedAt: 1_000,
|
||||
approval: {
|
||||
command: 'systemctl restart nginx',
|
||||
toolId: 'tool-1',
|
||||
toolName: 'pulse_control',
|
||||
runOnHost: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'question',
|
||||
startedAt: 2_000,
|
||||
updatedAt: 2_000,
|
||||
question: {
|
||||
questionId: 'question-1',
|
||||
questions: [{ id: 'target', type: 'text', question: 'Which node?' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
true,
|
||||
),
|
||||
).toEqual({
|
||||
type: 'thinking',
|
||||
text: 'Waiting for answer',
|
||||
startedAt: 2_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows generating status after visible assistant output starts streaming', () => {
|
||||
expect(
|
||||
getAssistantActiveTurnStatus(
|
||||
|
||||
@@ -1146,10 +1146,14 @@ describe('useChat', () => {
|
||||
|
||||
const assistant = chat.messages().find((m) => m.role === 'assistant')!;
|
||||
expect(assistant.model).toBe('gemini:gemini-3.1-flash-lite');
|
||||
expect(assistant.streamEvents).toContainEqual({
|
||||
type: 'model_switch',
|
||||
model: 'gemini:gemini-3.1-flash-lite',
|
||||
});
|
||||
expect(assistant.streamEvents).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'model_switch',
|
||||
model: 'gemini:gemini-3.1-flash-lite',
|
||||
startedAt: expect.any(Number),
|
||||
updatedAt: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
expect(assistant.workflowStatus).toEqual(
|
||||
expect.objectContaining({
|
||||
phase: 'provider_fallback',
|
||||
@@ -1323,6 +1327,14 @@ describe('useChat', () => {
|
||||
startedAt: expect.any(Number),
|
||||
updatedAt: expect.any(Number),
|
||||
});
|
||||
expect(assistant.streamEvents).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'pending_tool',
|
||||
toolId: 'tool-1',
|
||||
startedAt: expect.any(Number),
|
||||
updatedAt: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
dispose();
|
||||
});
|
||||
|
||||
@@ -1358,11 +1370,15 @@ describe('useChat', () => {
|
||||
});
|
||||
const pendingToolEvents = assistant.streamEvents?.filter((e) => e.type === 'pending_tool');
|
||||
expect(pendingToolEvents).toHaveLength(1);
|
||||
expect(pendingToolEvents![0].pendingTool).toMatchObject({
|
||||
id: 'tool-1',
|
||||
rawInput: '{"action": "logs"',
|
||||
status: 'running',
|
||||
progress: 'Running.',
|
||||
expect(pendingToolEvents![0]).toMatchObject({
|
||||
startedAt: expect.any(Number),
|
||||
updatedAt: expect.any(Number),
|
||||
pendingTool: {
|
||||
id: 'tool-1',
|
||||
rawInput: '{"action": "logs"',
|
||||
status: 'running',
|
||||
progress: 'Running.',
|
||||
},
|
||||
});
|
||||
dispose();
|
||||
});
|
||||
@@ -1596,6 +1612,13 @@ describe('useChat', () => {
|
||||
isExecuting: false,
|
||||
approvalId: 'appr-5',
|
||||
});
|
||||
expect(assistant.streamEvents).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'approval',
|
||||
startedAt: expect.any(Number),
|
||||
updatedAt: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
dispose();
|
||||
});
|
||||
|
||||
@@ -1632,6 +1655,13 @@ describe('useChat', () => {
|
||||
header: undefined,
|
||||
options: [{ label: 'Node 1', value: 'n1', description: 'Primary' }],
|
||||
});
|
||||
expect(assistant.streamEvents).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'question',
|
||||
startedAt: expect.any(Number),
|
||||
updatedAt: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
dispose();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ChatMessage, PendingTool, StreamDisplayEvent, WorkflowStatus } from './types';
|
||||
import { formatIdentifierLabel } from '@/utils/textPresentation';
|
||||
import { formatAIModelRouteLabel } from '@/utils/aiProviderPresentation';
|
||||
import { extractReasoningSummaryTitle } from './reasoningSummary';
|
||||
|
||||
export type AssistantActiveTurnStatusKind = 'thinking' | 'tool' | 'generating';
|
||||
@@ -206,6 +207,7 @@ const latestStreamActivityStatus = (
|
||||
candidate = {
|
||||
type: 'thinking',
|
||||
text: 'Waiting for approval',
|
||||
startedAt: event.startedAt,
|
||||
activityAt: eventActivityAt(event),
|
||||
order: index,
|
||||
};
|
||||
@@ -216,11 +218,25 @@ const latestStreamActivityStatus = (
|
||||
candidate = {
|
||||
type: 'thinking',
|
||||
text: 'Waiting for answer',
|
||||
startedAt: event.startedAt,
|
||||
activityAt: eventActivityAt(event),
|
||||
order: index,
|
||||
};
|
||||
}
|
||||
break;
|
||||
case 'model_switch': {
|
||||
const model = event.model?.trim();
|
||||
if (model) {
|
||||
candidate = {
|
||||
type: 'thinking',
|
||||
text: `Switched to ${formatAIModelRouteLabel(model)}`,
|
||||
startedAt: event.startedAt,
|
||||
activityAt: eventActivityAt(event),
|
||||
order: index,
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -244,11 +244,25 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
};
|
||||
|
||||
// Helper to add stream event for chronological display
|
||||
const withStreamEventTiming = (
|
||||
event: StreamDisplayEvent,
|
||||
now = Date.now(),
|
||||
): StreamDisplayEvent => {
|
||||
const startedAt = event.startedAt ?? event.pendingTool?.startedAt ?? now;
|
||||
const updatedAt = event.updatedAt ?? event.pendingTool?.updatedAt ?? startedAt;
|
||||
return {
|
||||
...event,
|
||||
startedAt,
|
||||
updatedAt,
|
||||
};
|
||||
};
|
||||
|
||||
const addStreamEvent = (msg: ChatMessage, event: StreamDisplayEvent): ChatMessage => {
|
||||
const nextEvent = withStreamEventTiming(event);
|
||||
const events = msg.streamEvents || [];
|
||||
|
||||
// For content events, merge consecutive content into one
|
||||
if (event.type === 'content' && events.length > 0) {
|
||||
if (nextEvent.type === 'content' && events.length > 0) {
|
||||
const last = events[events.length - 1];
|
||||
if (last.type === 'content') {
|
||||
const now = Date.now();
|
||||
@@ -258,9 +272,9 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
...events.slice(0, -1),
|
||||
{
|
||||
...last,
|
||||
content: (last.content || '') + (event.content || ''),
|
||||
startedAt: last.startedAt || event.startedAt || now,
|
||||
updatedAt: event.updatedAt || now,
|
||||
content: (last.content || '') + (nextEvent.content || ''),
|
||||
startedAt: last.startedAt || nextEvent.startedAt || now,
|
||||
updatedAt: nextEvent.updatedAt || now,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -268,7 +282,7 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
}
|
||||
|
||||
// For thinking events, merge consecutive thinking into one
|
||||
if (event.type === 'thinking' && events.length > 0) {
|
||||
if (nextEvent.type === 'thinking' && events.length > 0) {
|
||||
const last = events[events.length - 1];
|
||||
if (last.type === 'thinking') {
|
||||
const now = Date.now();
|
||||
@@ -278,9 +292,9 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
...events.slice(0, -1),
|
||||
{
|
||||
...last,
|
||||
thinking: (last.thinking || '') + (event.thinking || ''),
|
||||
startedAt: last.startedAt || event.startedAt || now,
|
||||
updatedAt: event.updatedAt || now,
|
||||
thinking: (last.thinking || '') + (nextEvent.thinking || ''),
|
||||
startedAt: last.startedAt || nextEvent.startedAt || now,
|
||||
updatedAt: nextEvent.updatedAt || now,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -289,7 +303,7 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
|
||||
return {
|
||||
...msg,
|
||||
streamEvents: [...events, event],
|
||||
streamEvents: [...events, nextEvent],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -365,19 +379,27 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
return event;
|
||||
}
|
||||
replacedEvent = true;
|
||||
return {
|
||||
...event,
|
||||
toolId: resolvedTool?.id || event.toolId,
|
||||
pendingTool: resolvedTool,
|
||||
};
|
||||
return withStreamEventTiming(
|
||||
{
|
||||
...event,
|
||||
toolId: resolvedTool?.id || event.toolId,
|
||||
pendingTool: resolvedTool,
|
||||
},
|
||||
now,
|
||||
);
|
||||
});
|
||||
|
||||
if (!replacedEvent && resolvedTool) {
|
||||
updatedEvents.push({
|
||||
type: 'pending_tool',
|
||||
pendingTool: resolvedTool,
|
||||
toolId: resolvedTool.id,
|
||||
});
|
||||
updatedEvents.push(
|
||||
withStreamEventTiming(
|
||||
{
|
||||
type: 'pending_tool',
|
||||
pendingTool: resolvedTool,
|
||||
toolId: resolvedTool.id,
|
||||
},
|
||||
now,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -434,8 +456,12 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
...events.slice(0, boundaryIndex + 1),
|
||||
...events.slice(boundaryIndex + 1).filter((evt) => evt.type !== 'content'),
|
||||
];
|
||||
const now = Date.now();
|
||||
const streamEvents = replacementText
|
||||
? [...retainedEvents, { type: 'content' as const, content: replacementText }]
|
||||
? [
|
||||
...retainedEvents,
|
||||
withStreamEventTiming({ type: 'content' as const, content: replacementText }, now),
|
||||
]
|
||||
: retainedEvents;
|
||||
|
||||
let content = msg.content || '';
|
||||
@@ -1478,6 +1504,7 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
}
|
||||
|
||||
const shouldUpdateExecuting = typeof update.isExecuting === 'boolean';
|
||||
const updatedAt = Date.now();
|
||||
|
||||
// Update the approval in place
|
||||
return {
|
||||
@@ -1494,6 +1521,7 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
}
|
||||
return {
|
||||
...event,
|
||||
updatedAt,
|
||||
approval: {
|
||||
...event.approval,
|
||||
isExecuting: update.isExecuting,
|
||||
@@ -1516,7 +1544,10 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
return {
|
||||
...msg,
|
||||
toolCalls: [...(msg.toolCalls || []), toolCall],
|
||||
streamEvents: [...(msg.streamEvents || []), { type: 'tool' as const, tool: toolCall }],
|
||||
streamEvents: [
|
||||
...(msg.streamEvents || []),
|
||||
withStreamEventTiming({ type: 'tool' as const, tool: toolCall }),
|
||||
],
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -1545,6 +1576,7 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
}
|
||||
|
||||
const shouldUpdateAnswering = typeof update.isAnswering === 'boolean';
|
||||
const updatedAt = Date.now();
|
||||
|
||||
// Update the question in place
|
||||
return {
|
||||
@@ -1561,6 +1593,7 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
}
|
||||
return {
|
||||
...event,
|
||||
updatedAt,
|
||||
question: {
|
||||
...event.question,
|
||||
isAnswering: update.isAnswering,
|
||||
|
||||
Reference in New Issue
Block a user