Timestamp Assistant progress stream events

This commit is contained in:
rcourtman
2026-06-06 13:33:36 +01:00
parent 32482a5472
commit a7ca1da7ee
5 changed files with 217 additions and 36 deletions
@@ -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,