From a7ca1da7ee6873e626370c421a06a44ec93dbdd5 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 6 Jun 2026 13:33:36 +0100 Subject: [PATCH] Timestamp Assistant progress stream events --- .../v6/internal/subsystems/ai-runtime.md | 12 +-- .../Chat/__tests__/activeTurnStatus.test.ts | 102 ++++++++++++++++++ .../AI/Chat/__tests__/useChat.test.ts | 48 +++++++-- .../components/AI/Chat/activeTurnStatus.ts | 16 +++ .../src/components/AI/Chat/hooks/useChat.ts | 75 +++++++++---- 5 files changed, 217 insertions(+), 36 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index f5d7d6cd9..f01347fa7 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -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 diff --git a/frontend-modern/src/components/AI/Chat/__tests__/activeTurnStatus.test.ts b/frontend-modern/src/components/AI/Chat/__tests__/activeTurnStatus.test.ts index ac1455df0..16a8f15ff 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/activeTurnStatus.test.ts +++ b/frontend-modern/src/components/AI/Chat/__tests__/activeTurnStatus.test.ts @@ -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( diff --git a/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts b/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts index 008798cd0..3d6865b1b 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts +++ b/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts @@ -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(); }); diff --git a/frontend-modern/src/components/AI/Chat/activeTurnStatus.ts b/frontend-modern/src/components/AI/Chat/activeTurnStatus.ts index 71a0d7b88..5a1ff7d90 100644 --- a/frontend-modern/src/components/AI/Chat/activeTurnStatus.ts +++ b/frontend-modern/src/components/AI/Chat/activeTurnStatus.ts @@ -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; } diff --git a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts index d4a6a9cf2..fcb78219c 100644 --- a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts +++ b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts @@ -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,