Suppress stale Assistant workflow status

This commit is contained in:
rcourtman
2026-06-06 06:15:20 +01:00
parent 3d5e4b7345
commit 0d8b90ce00
9 changed files with 122 additions and 9 deletions
@@ -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
@@ -164,7 +164,11 @@ export const MessageItem: Component<MessageItemProps> = (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':
@@ -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(() => (
<MessageItem
message={makeMessage({
@@ -563,7 +563,9 @@ describe('MessageItem', () => {
));
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();
});
@@ -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',
});
});
});
@@ -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' }));
@@ -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' };
};
@@ -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 };
}),
+2
View File
@@ -78,6 +78,7 @@ var (
"🔴", "", "🟠", "", "🟡", "", "🟢", "", "🔵", "", "🟣", "", "🟤", "", "⚫", "", "⚪", "",
"✅", "", "❌", "", "❎", "", "✔️", "", "✔", "", "☑️", "", "☑", "", "✖️", "", "✖", "", "✗", "", "✘", "",
"️", "", "", "", "❗", "", "❕", "", "❓", "", "❔", "",
"🤖", "",
"🔧", "", "🛠️", "", "🛠", "", "🧰", "",
"🔥", "", "💡", "", "📌", "", "📍", "", "📊", "", "📈", "", "📉", "",
)
@@ -86,6 +87,7 @@ var (
"🔴", "🟠", "🟡", "🟢", "🔵", "🟣", "🟤", "⚫", "⚪",
"✅", "❌", "❎", "✔️", "✔", "☑️", "☑", "✖️", "✖", "✗", "✘",
"️", "", "❗", "❕", "❓", "❔",
"🤖",
"🔧", "🛠️", "🛠", "🧰",
"🔥", "💡", "📌", "📍", "📊", "📈", "📉",
}
+2 -2
View File
@@ -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 {