mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-21 18:53:37 +00:00
Suppress stale Assistant workflow status
This commit is contained in:
@@ -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 };
|
||||
}),
|
||||
|
||||
@@ -78,6 +78,7 @@ var (
|
||||
"🔴", "", "🟠", "", "🟡", "", "🟢", "", "🔵", "", "🟣", "", "🟤", "", "⚫", "", "⚪", "",
|
||||
"✅", "", "❌", "", "❎", "", "✔️", "", "✔", "", "☑️", "", "☑", "", "✖️", "", "✖", "", "✗", "", "✘", "",
|
||||
"ℹ️", "", "ℹ", "", "❗", "", "❕", "", "❓", "", "❔", "",
|
||||
"🤖", "",
|
||||
"🔧", "", "🛠️", "", "🛠", "", "🧰", "",
|
||||
"🔥", "", "💡", "", "📌", "", "📍", "", "📊", "", "📈", "", "📉", "",
|
||||
)
|
||||
@@ -86,6 +87,7 @@ var (
|
||||
"🔴", "🟠", "🟡", "🟢", "🔵", "🟣", "🟤", "⚫", "⚪",
|
||||
"✅", "❌", "❎", "✔️", "✔", "☑️", "☑", "✖️", "✖", "✗", "✘",
|
||||
"ℹ️", "ℹ", "❗", "❕", "❓", "❔",
|
||||
"🤖",
|
||||
"🔧", "🛠️", "🛠", "🧰",
|
||||
"🔥", "💡", "📌", "📍", "📊", "📈", "📉",
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user