From 0d8b90ce00344fef953ee912cee7549fb67f778c Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 6 Jun 2026 06:15:20 +0100 Subject: [PATCH] Suppress stale Assistant workflow status --- .../v6/internal/subsystems/ai-runtime.md | 8 ++++ .../src/components/AI/Chat/MessageItem.tsx | 6 ++- .../AI/Chat/__tests__/MessageItem.test.tsx | 6 ++- .../Chat/__tests__/activeTurnStatus.test.ts | 32 +++++++++++++++ .../AI/Chat/__tests__/useChat.test.ts | 40 +++++++++++++++++++ .../components/AI/Chat/activeTurnStatus.ts | 8 ++-- .../src/components/AI/Chat/hooks/useChat.ts | 25 ++++++++++++ internal/ai/chat/agentic_sanitize.go | 2 + internal/ai/chat/agentic_sanitize_test.go | 4 +- 9 files changed, 122 insertions(+), 9 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 23ddb4720..a4f3a2777 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -213,6 +213,14 @@ runtime cost control, and shared AI transport surfaces. workflow and pending-tool activity must retain a per-state start timestamp so the drawer can show elapsed wait/run time for long provider starts and tool calls instead of repeating a timeless waiting label. + The referenced OpenCode source at fetched `origin/dev` commit + `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, so later neutral workflow states such as + provider reasoning do not repaint a completed tool row as if the turn were + still waiting on the earlier phase. OpenCode-parity Assistant UX work must reference OpenCode's actual source implementation for message parts, tool-state mutation, progress rendering, and model/session selection before changing Pulse behavior; parity means diff --git a/frontend-modern/src/components/AI/Chat/MessageItem.tsx b/frontend-modern/src/components/AI/Chat/MessageItem.tsx index 9b329cd95..5e7cefdd9 100644 --- a/frontend-modern/src/components/AI/Chat/MessageItem.tsx +++ b/frontend-modern/src/components/AI/Chat/MessageItem.tsx @@ -164,7 +164,11 @@ export const MessageItem: Component = (props) => { return workflowStatusText() || 'Thinking...'; }); const shouldShowHeaderWorkflowStatus = () => - props.message.isStreaming && !isWaitingForFirstToken() && !!workflowStatusText(); + props.message.isStreaming && + !isWaitingForFirstToken() && + !visibleMessageContent().trim() && + !hasRenderableStreamEvents() && + !!workflowStatusText(); const interruptionLabel = createMemo(() => { switch (props.message.interruption) { case 'replaced': diff --git a/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx index abf85266a..0a4ac0c4c 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx @@ -544,7 +544,7 @@ describe('MessageItem', () => { expect(screen.queryByText('Thinking...')).not.toBeInTheDocument(); }); - it('shows current workflow progress in the assistant header after visible content starts', () => { + it('hides stale workflow progress after visible content starts', () => { render(() => ( { )); expect(screen.getByText('Partial answer')).toBeInTheDocument(); - expect(screen.getByText('Sent request to OpenRouter; waiting for the first token.')).toBeInTheDocument(); + expect( + screen.queryByText('Sent request to OpenRouter; waiting for the first token.'), + ).not.toBeInTheDocument(); expect(screen.queryByText('Thinking...')).not.toBeInTheDocument(); }); 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 b7c0a5cf6..686a76e95 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/activeTurnStatus.test.ts +++ b/frontend-modern/src/components/AI/Chat/__tests__/activeTurnStatus.test.ts @@ -174,4 +174,36 @@ describe('getAssistantActiveTurnStatus', () => { text: 'Generating response', }); }); + + it('prefers generated tool evidence over stale neutral workflow status', () => { + expect( + getAssistantActiveTurnStatus( + [ + assistantMessage({ + workflowStatus: { + phase: 'model_thinking', + message: 'Model is reasoning before responding.', + startedAt: 1000, + }, + streamEvents: [ + { + type: 'tool', + toolId: 'tool-1', + tool: { + name: 'pulse_alerts', + input: '{}', + output: '11 active alerts', + success: true, + }, + }, + ], + }), + ], + true, + ), + ).toEqual({ + type: 'generating', + text: 'Generating response', + }); + }); }); 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 fa65b7313..00923b3ab 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts +++ b/frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts @@ -1146,6 +1146,46 @@ describe('useChat', () => { dispose(); }); + it('ignores neutral workflow progress after typed tool evidence is visible', async () => { + const { getFireEvent } = setupWithEventCapture(); + const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 's' })); + + await chat.sendMessage('hi'); + const fire = getFireEvent(); + + fire({ + type: 'workflow_state', + data: { + phase: 'provider_start', + message: 'Sent request to OpenRouter; waiting for the first token.', + }, + }); + fire({ type: 'tool_start', data: { id: 'tool-1', name: 'pulse_alerts', input: '{}' } }); + fire({ + type: 'tool_end', + data: { + id: 'tool-1', + name: 'pulse_alerts', + input: '{}', + output: '11 active alerts', + success: true, + }, + }); + fire({ + type: 'workflow_state', + data: { + phase: 'model_thinking', + message: 'Model is reasoning before responding.', + }, + }); + + const assistant = chat.messages().find((m) => m.role === 'assistant')!; + expect(assistant.workflowStatus).toBeUndefined(); + expect(assistant.streamEvents?.map((event) => event.type)).toEqual(['tool']); + expect(assistant.toolCalls).toHaveLength(1); + dispose(); + }); + it('processes tool_start events', async () => { const { getFireEvent } = setupWithEventCapture(); const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 's' })); diff --git a/frontend-modern/src/components/AI/Chat/activeTurnStatus.ts b/frontend-modern/src/components/AI/Chat/activeTurnStatus.ts index 110f36618..52fc174d9 100644 --- a/frontend-modern/src/components/AI/Chat/activeTurnStatus.ts +++ b/frontend-modern/src/components/AI/Chat/activeTurnStatus.ts @@ -105,6 +105,10 @@ export const getAssistantActiveTurnStatus = ( } const workflowStatusText = formatAssistantWorkflowStatus(assistantMessage.workflowStatus); + if (hasVisibleAssistantOutput(assistantMessage)) { + return { type: 'generating', text: 'Generating response' }; + } + if (workflowStatusText) { return { type: assistantMessage.workflowStatus?.tool ? 'tool' : 'thinking', @@ -113,9 +117,5 @@ export const getAssistantActiveTurnStatus = ( }; } - if (hasVisibleAssistantOutput(assistantMessage)) { - return { type: 'generating', text: 'Generating response' }; - } - return { type: 'thinking', text: 'Waiting for assistant' }; }; diff --git a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts index 55d9d3556..5da192c88 100644 --- a/frontend-modern/src/components/AI/Chat/hooks/useChat.ts +++ b/frontend-modern/src/components/AI/Chat/hooks/useChat.ts @@ -120,6 +120,28 @@ export function useChat(options: UseChatOptions = {}) { (a.state || '') === (b.state || '') && (a.tool || '') === (b.tool || ''); + const streamEventHasTypedAssistantEvidence = (event: StreamDisplayEvent) => { + switch (event.type) { + case 'content': + return !!event.content?.trim(); + case 'pending_tool': + return !!event.pendingTool; + case 'tool': + return !!event.tool; + case 'approval': + return !!event.approval; + case 'question': + return !!event.question; + default: + return false; + } + }; + + const messageHasTypedAssistantEvidence = (message: ChatMessage) => { + if ((message.content || '').trim() || message.error) return true; + return (message.streamEvents || []).some(streamEventHasTypedAssistantEvidence); + }; + const setAssistantWorkflowStatus = ( assistantId: string, requestId: number, @@ -129,6 +151,9 @@ export function useChat(options: UseChatOptions = {}) { setMessages((prev) => prev.map((msg) => { if (msg.id !== assistantId) return msg; + if (messageHasTypedAssistantEvidence(msg)) { + return msg.workflowStatus ? { ...msg, workflowStatus: undefined } : msg; + } if (workflowStatusesMatch(msg.workflowStatus, workflowStatus)) return msg; return { ...msg, workflowStatus }; }), diff --git a/internal/ai/chat/agentic_sanitize.go b/internal/ai/chat/agentic_sanitize.go index a53f48dce..72cacffc9 100644 --- a/internal/ai/chat/agentic_sanitize.go +++ b/internal/ai/chat/agentic_sanitize.go @@ -78,6 +78,7 @@ var ( "πŸ”΄", "", "🟠", "", "🟑", "", "🟒", "", "πŸ”΅", "", "🟣", "", "🟀", "", "⚫", "", "βšͺ", "", "βœ…", "", "❌", "", "❎", "", "βœ”οΈ", "", "βœ”", "", "β˜‘οΈ", "", "β˜‘", "", "βœ–οΈ", "", "βœ–", "", "βœ—", "", "✘", "", "ℹ️", "", "β„Ή", "", "❗", "", "❕", "", "❓", "", "❔", "", + "πŸ€–", "", "πŸ”§", "", "πŸ› οΈ", "", "πŸ› ", "", "🧰", "", "πŸ”₯", "", "πŸ’‘", "", "πŸ“Œ", "", "πŸ“", "", "πŸ“Š", "", "πŸ“ˆ", "", "πŸ“‰", "", ) @@ -86,6 +87,7 @@ var ( "πŸ”΄", "🟠", "🟑", "🟒", "πŸ”΅", "🟣", "🟀", "⚫", "βšͺ", "βœ…", "❌", "❎", "βœ”οΈ", "βœ”", "β˜‘οΈ", "β˜‘", "βœ–οΈ", "βœ–", "βœ—", "✘", "ℹ️", "β„Ή", "❗", "❕", "❓", "❔", + "πŸ€–", "πŸ”§", "πŸ› οΈ", "πŸ› ", "🧰", "πŸ”₯", "πŸ’‘", "πŸ“Œ", "πŸ“", "πŸ“Š", "πŸ“ˆ", "πŸ“‰", } diff --git a/internal/ai/chat/agentic_sanitize_test.go b/internal/ai/chat/agentic_sanitize_test.go index 13d2198f2..8ff20e9eb 100644 --- a/internal/ai/chat/agentic_sanitize_test.go +++ b/internal/ai/chat/agentic_sanitize_test.go @@ -123,8 +123,8 @@ func TestCleanToolCallArtifacts(t *testing.T) { } func TestCleanToolCallArtifactsCleansDecorativeOperationalSymbols(t *testing.T) { - input := "### πŸ”΄ Critical Alerts\n###⚠️Warnings\n- ⚠️ Active AI Patrol Finding\n3.βœ… Backup is healthy\nCheck ⚠️ the alert, then βœ… the backup.\nNext Steps:βœ…Would you like me to investigate?\nTemperature is 58Β°C.\n\n```text\n⚠️ literal status stays inside code\n```\n" - expected := "### Critical Alerts\n### Warnings\n- Active AI Patrol Finding\n3. Backup is healthy\nCheck the alert, then the backup.\nNext Steps: Would you like me to investigate?\nTemperature is 58Β°C.\n\n```text\n⚠️ literal status stays inside code\n```\n" + input := "### πŸ”΄ Critical Alerts\n###⚠️Warnings\n- ⚠️ Active AI Patrol Finding\n- πŸ€– AI Patrol Finding\n3.βœ… Backup is healthy\nCheck ⚠️ the alert, then βœ… the backup.\nNext Steps:βœ…Would you like me to investigate?\nTemperature is 58Β°C.\n\n```text\n⚠️ literal status stays inside code\n```\n" + expected := "### Critical Alerts\n### Warnings\n- Active AI Patrol Finding\n- AI Patrol Finding\n3. Backup is healthy\nCheck the alert, then the backup.\nNext Steps: Would you like me to investigate?\nTemperature is 58Β°C.\n\n```text\n⚠️ literal status stays inside code\n```\n" got := cleanToolCallArtifacts(input) if got != expected {