Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
Commit ·
d2c1b12
1
Parent(s): 0cee198
feat: render cancelled tool state in UI after user interrupt
Browse filesSend tool_state_change(cancelled) events for in-flight tools so the
frontend can flip spinners to a muted "cancelled" state. Keep SSE
stream open (don't call chat.stop()) so backend events arrive.
Show "What should the agent do instead?" placeholder after cancel.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
agent/core/agent_loop.py
CHANGED
|
@@ -577,6 +577,13 @@ class Handlers:
|
|
| 577 |
await gather_task
|
| 578 |
except asyncio.CancelledError:
|
| 579 |
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 580 |
await _cleanup_on_cancel(session)
|
| 581 |
break
|
| 582 |
|
|
@@ -834,6 +841,12 @@ class Handlers:
|
|
| 834 |
await gather_task
|
| 835 |
except asyncio.CancelledError:
|
| 836 |
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 837 |
await _cleanup_on_cancel(session)
|
| 838 |
await session.send_event(Event(event_type="interrupted"))
|
| 839 |
session.increment_turn()
|
|
|
|
| 577 |
await gather_task
|
| 578 |
except asyncio.CancelledError:
|
| 579 |
pass
|
| 580 |
+
# Notify frontend that in-flight tools were cancelled
|
| 581 |
+
for tc, name, _args, valid, _ in parsed_tools:
|
| 582 |
+
if valid:
|
| 583 |
+
await session.send_event(Event(
|
| 584 |
+
event_type="tool_state_change",
|
| 585 |
+
data={"tool_call_id": tc.id, "tool": name, "state": "cancelled"},
|
| 586 |
+
))
|
| 587 |
await _cleanup_on_cancel(session)
|
| 588 |
break
|
| 589 |
|
|
|
|
| 841 |
await gather_task
|
| 842 |
except asyncio.CancelledError:
|
| 843 |
pass
|
| 844 |
+
# Notify frontend that approved tools were cancelled
|
| 845 |
+
for tc, tool_name, _args, _was_edited in approved_tasks:
|
| 846 |
+
await session.send_event(Event(
|
| 847 |
+
event_type="tool_state_change",
|
| 848 |
+
data={"tool_call_id": tc.id, "tool": tool_name, "state": "cancelled"},
|
| 849 |
+
))
|
| 850 |
await _cleanup_on_cancel(session)
|
| 851 |
await session.send_event(Event(event_type="interrupted"))
|
| 852 |
session.increment_turn()
|
frontend/src/components/Chat/ToolCallGroup.tsx
CHANGED
|
@@ -19,6 +19,13 @@ type DynamicToolPart = Extract<UIMessage['parts'][number], { type: 'dynamic-tool
|
|
| 19 |
|
| 20 |
type ToolPartState = DynamicToolPart['state'];
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
interface ToolCallGroupProps {
|
| 23 |
tools: DynamicToolPart[];
|
| 24 |
approveTools: (approvals: Array<{ tool_call_id: string; approved: boolean; feedback?: string | null; edited_script?: string | null }>) => Promise<boolean>;
|
|
@@ -54,7 +61,10 @@ function costLabel(hardware: string): string | null {
|
|
| 54 |
// Visual helpers
|
| 55 |
// ---------------------------------------------------------------------------
|
| 56 |
|
| 57 |
-
function StatusIcon({ state }: { state: ToolPartState }) {
|
|
|
|
|
|
|
|
|
|
| 58 |
switch (state) {
|
| 59 |
case 'approval-requested':
|
| 60 |
return <HourglassEmptyIcon sx={{ fontSize: 16, color: 'var(--accent-yellow)' }} />;
|
|
@@ -563,10 +573,11 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
|
|
| 563 |
!!tool.input;
|
| 564 |
const localDecision = decisions[tool.toolCallId];
|
| 565 |
|
|
|
|
| 566 |
const displayState = isPending && localDecision
|
| 567 |
? (localDecision.approved ? 'input-available' : 'output-denied')
|
| 568 |
: state;
|
| 569 |
-
const label = statusLabel(displayState as ToolPartState);
|
| 570 |
|
| 571 |
// Parse job metadata from hf_jobs output and store
|
| 572 |
const jobUrlFromStore = tool.toolName === 'hf_jobs' ? getJobUrl(tool.toolCallId) : undefined;
|
|
@@ -596,11 +607,14 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
|
|
| 596 |
'&:hover': clickable && !isPending ? { bgcolor: 'var(--hover-bg)' } : {},
|
| 597 |
}}
|
| 598 |
>
|
| 599 |
-
<StatusIcon
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
|
|
|
|
|
|
|
|
|
| 604 |
|
| 605 |
<Typography
|
| 606 |
variant="body2"
|
|
@@ -628,10 +642,11 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
|
|
| 628 |
height: 20,
|
| 629 |
fontSize: '0.65rem',
|
| 630 |
fontWeight: 600,
|
| 631 |
-
bgcolor:
|
|
|
|
| 632 |
: displayState === 'output-denied' ? 'rgba(255,255,255,0.05)'
|
| 633 |
: 'var(--accent-yellow-weak)',
|
| 634 |
-
color: statusColor(displayState as ToolPartState),
|
| 635 |
letterSpacing: '0.03em',
|
| 636 |
}}
|
| 637 |
/>
|
|
|
|
| 19 |
|
| 20 |
type ToolPartState = DynamicToolPart['state'];
|
| 21 |
|
| 22 |
+
/** Check if a tool part was cancelled (output-error with cancellation message). */
|
| 23 |
+
function isCancelledTool(tool: DynamicToolPart): boolean {
|
| 24 |
+
return tool.state === 'output-error' &&
|
| 25 |
+
typeof (tool as Record<string, unknown>).errorText === 'string' &&
|
| 26 |
+
((tool as Record<string, unknown>).errorText as string).includes('Cancelled by user');
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
interface ToolCallGroupProps {
|
| 30 |
tools: DynamicToolPart[];
|
| 31 |
approveTools: (approvals: Array<{ tool_call_id: string; approved: boolean; feedback?: string | null; edited_script?: string | null }>) => Promise<boolean>;
|
|
|
|
| 61 |
// Visual helpers
|
| 62 |
// ---------------------------------------------------------------------------
|
| 63 |
|
| 64 |
+
function StatusIcon({ state, cancelled }: { state: ToolPartState; cancelled?: boolean }) {
|
| 65 |
+
if (cancelled) {
|
| 66 |
+
return <BlockIcon sx={{ fontSize: 16, color: 'var(--muted-text)' }} />;
|
| 67 |
+
}
|
| 68 |
switch (state) {
|
| 69 |
case 'approval-requested':
|
| 70 |
return <HourglassEmptyIcon sx={{ fontSize: 16, color: 'var(--accent-yellow)' }} />;
|
|
|
|
| 573 |
!!tool.input;
|
| 574 |
const localDecision = decisions[tool.toolCallId];
|
| 575 |
|
| 576 |
+
const cancelled = isCancelledTool(tool);
|
| 577 |
const displayState = isPending && localDecision
|
| 578 |
? (localDecision.approved ? 'input-available' : 'output-denied')
|
| 579 |
: state;
|
| 580 |
+
const label = cancelled ? 'cancelled' : statusLabel(displayState as ToolPartState);
|
| 581 |
|
| 582 |
// Parse job metadata from hf_jobs output and store
|
| 583 |
const jobUrlFromStore = tool.toolName === 'hf_jobs' ? getJobUrl(tool.toolCallId) : undefined;
|
|
|
|
| 607 |
'&:hover': clickable && !isPending ? { bgcolor: 'var(--hover-bg)' } : {},
|
| 608 |
}}
|
| 609 |
>
|
| 610 |
+
<StatusIcon
|
| 611 |
+
cancelled={cancelled}
|
| 612 |
+
state={
|
| 613 |
+
(tool.toolName === 'hf_jobs' && jobMeta.jobStatus && ['ERROR', 'FAILED', 'CANCELLED'].includes(jobMeta.jobStatus) && displayState === 'output-available')
|
| 614 |
+
? 'output-error'
|
| 615 |
+
: displayState as ToolPartState
|
| 616 |
+
}
|
| 617 |
+
/>
|
| 618 |
|
| 619 |
<Typography
|
| 620 |
variant="body2"
|
|
|
|
| 642 |
height: 20,
|
| 643 |
fontSize: '0.65rem',
|
| 644 |
fontWeight: 600,
|
| 645 |
+
bgcolor: cancelled ? 'rgba(255,255,255,0.05)'
|
| 646 |
+
: displayState === 'output-error' ? 'rgba(224,90,79,0.12)'
|
| 647 |
: displayState === 'output-denied' ? 'rgba(255,255,255,0.05)'
|
| 648 |
: 'var(--accent-yellow-weak)',
|
| 649 |
+
color: cancelled ? 'var(--muted-text)' : statusColor(displayState as ToolPartState),
|
| 650 |
letterSpacing: '0.03em',
|
| 651 |
}}
|
| 652 |
/>
|
frontend/src/components/SessionChat.tsx
CHANGED
|
@@ -5,7 +5,7 @@
|
|
| 5 |
* runs — processing events — but only the active session renders visible
|
| 6 |
* UI (MessageList + ChatInput).
|
| 7 |
*/
|
| 8 |
-
import { useCallback, useEffect } from 'react';
|
| 9 |
import { useAgentChat } from '@/hooks/useAgentChat';
|
| 10 |
import { useAgentStore } from '@/store/agentStore';
|
| 11 |
import { useSessionStore } from '@/store/sessionStore';
|
|
@@ -24,6 +24,8 @@ export default function SessionChat({ sessionId, isActive, onSessionDead }: Sess
|
|
| 24 |
const { isConnected, isProcessing, activityStatus, updateSession } = useAgentStore();
|
| 25 |
const { updateSessionTitle } = useSessionStore();
|
| 26 |
|
|
|
|
|
|
|
| 27 |
const { messages, sendMessage, stop, status, undoLastTurn, approveTools } = useAgentChat({
|
| 28 |
sessionId,
|
| 29 |
isActive,
|
|
@@ -55,6 +57,12 @@ export default function SessionChat({ sessionId, isActive, onSessionDead }: Sess
|
|
| 55 |
return () => document.removeEventListener('visibilitychange', onVisible);
|
| 56 |
}, [isActive, sessionId]);
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
// SDK status is the ground truth — if it's streaming/submitted, agent is busy
|
| 59 |
const sdkBusy = status === 'streaming' || status === 'submitted';
|
| 60 |
const busy = isProcessing || sdkBusy;
|
|
@@ -63,6 +71,7 @@ export default function SessionChat({ sessionId, isActive, onSessionDead }: Sess
|
|
| 63 |
async (text: string) => {
|
| 64 |
if (!text.trim() || busy) return;
|
| 65 |
|
|
|
|
| 66 |
updateSession(sessionId, { isProcessing: true });
|
| 67 |
sendMessage({ text: text.trim(), metadata: { createdAt: new Date().toISOString() } });
|
| 68 |
|
|
@@ -99,10 +108,16 @@ export default function SessionChat({ sessionId, isActive, onSessionDead }: Sess
|
|
| 99 |
/>
|
| 100 |
<ChatInput
|
| 101 |
onSend={handleSendMessage}
|
| 102 |
-
onStop={
|
| 103 |
isProcessing={busy}
|
| 104 |
disabled={!isConnected || activityStatus.type === 'waiting-approval'}
|
| 105 |
-
placeholder={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
/>
|
| 107 |
</>
|
| 108 |
);
|
|
|
|
| 5 |
* runs — processing events — but only the active session renders visible
|
| 6 |
* UI (MessageList + ChatInput).
|
| 7 |
*/
|
| 8 |
+
import { useCallback, useEffect, useState } from 'react';
|
| 9 |
import { useAgentChat } from '@/hooks/useAgentChat';
|
| 10 |
import { useAgentStore } from '@/store/agentStore';
|
| 11 |
import { useSessionStore } from '@/store/sessionStore';
|
|
|
|
| 24 |
const { isConnected, isProcessing, activityStatus, updateSession } = useAgentStore();
|
| 25 |
const { updateSessionTitle } = useSessionStore();
|
| 26 |
|
| 27 |
+
const [wasCancelled, setWasCancelled] = useState(false);
|
| 28 |
+
|
| 29 |
const { messages, sendMessage, stop, status, undoLastTurn, approveTools } = useAgentChat({
|
| 30 |
sessionId,
|
| 31 |
isActive,
|
|
|
|
| 57 |
return () => document.removeEventListener('visibilitychange', onVisible);
|
| 58 |
}, [isActive, sessionId]);
|
| 59 |
|
| 60 |
+
// Wrap stop to track cancellation
|
| 61 |
+
const handleStop = useCallback(() => {
|
| 62 |
+
stop();
|
| 63 |
+
setWasCancelled(true);
|
| 64 |
+
}, [stop]);
|
| 65 |
+
|
| 66 |
// SDK status is the ground truth — if it's streaming/submitted, agent is busy
|
| 67 |
const sdkBusy = status === 'streaming' || status === 'submitted';
|
| 68 |
const busy = isProcessing || sdkBusy;
|
|
|
|
| 71 |
async (text: string) => {
|
| 72 |
if (!text.trim() || busy) return;
|
| 73 |
|
| 74 |
+
setWasCancelled(false);
|
| 75 |
updateSession(sessionId, { isProcessing: true });
|
| 76 |
sendMessage({ text: text.trim(), metadata: { createdAt: new Date().toISOString() } });
|
| 77 |
|
|
|
|
| 108 |
/>
|
| 109 |
<ChatInput
|
| 110 |
onSend={handleSendMessage}
|
| 111 |
+
onStop={handleStop}
|
| 112 |
isProcessing={busy}
|
| 113 |
disabled={!isConnected || activityStatus.type === 'waiting-approval'}
|
| 114 |
+
placeholder={
|
| 115 |
+
activityStatus.type === 'waiting-approval'
|
| 116 |
+
? 'Approve or reject pending tools first...'
|
| 117 |
+
: wasCancelled
|
| 118 |
+
? 'What should the agent do instead?'
|
| 119 |
+
: undefined
|
| 120 |
+
}
|
| 121 |
/>
|
| 122 |
</>
|
| 123 |
);
|
frontend/src/hooks/useAgentChat.ts
CHANGED
|
@@ -223,6 +223,7 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
|
|
| 223 |
onToolRunning: (toolName: string, description?: string) => {
|
| 224 |
updateSession(sessionId, { activityStatus: { type: 'tool', toolName, description } });
|
| 225 |
},
|
|
|
|
| 226 |
}),
|
| 227 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 228 |
[sessionId],
|
|
@@ -586,11 +587,14 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
|
|
| 586 |
[sessionId, chat, updateSession, setNeedsAttention],
|
| 587 |
);
|
| 588 |
|
| 589 |
-
// -- Stop (
|
| 590 |
const stop = useCallback(() => {
|
| 591 |
-
chat.stop()
|
|
|
|
|
|
|
|
|
|
| 592 |
apiFetch(`/api/interrupt/${sessionId}`, { method: 'POST' }).catch(() => {});
|
| 593 |
-
}, [sessionId,
|
| 594 |
|
| 595 |
return {
|
| 596 |
messages: chat.messages,
|
|
|
|
| 223 |
onToolRunning: (toolName: string, description?: string) => {
|
| 224 |
updateSession(sessionId, { activityStatus: { type: 'tool', toolName, description } });
|
| 225 |
},
|
| 226 |
+
onInterrupted: () => { /* no-op — handled by stop() caller */ },
|
| 227 |
}),
|
| 228 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 229 |
[sessionId],
|
|
|
|
| 587 |
[sessionId, chat, updateSession, setNeedsAttention],
|
| 588 |
);
|
| 589 |
|
| 590 |
+
// -- Stop (interrupt backend agent loop, keep SSE open for events) --------
|
| 591 |
const stop = useCallback(() => {
|
| 592 |
+
// Don't call chat.stop() — keep the SSE stream open so the backend's
|
| 593 |
+
// tool_state_change(cancelled) and interrupted events reach the frontend.
|
| 594 |
+
// The stream closes naturally when the backend sends finish events.
|
| 595 |
+
updateSession(sessionId, { isProcessing: false });
|
| 596 |
apiFetch(`/api/interrupt/${sessionId}`, { method: 'POST' }).catch(() => {});
|
| 597 |
+
}, [sessionId, updateSession]);
|
| 598 |
|
| 599 |
return {
|
| 600 |
messages: chat.messages,
|
frontend/src/lib/sse-chat-transport.ts
CHANGED
|
@@ -31,6 +31,7 @@ export interface SideChannelCallbacks {
|
|
| 31 |
onToolOutputPanel: (tool: string, toolCallId: string, output: string, success: boolean) => void;
|
| 32 |
onStreaming: () => void;
|
| 33 |
onToolRunning: (toolName: string, description?: string) => void;
|
|
|
|
| 34 |
}
|
| 35 |
|
| 36 |
// ---------------------------------------------------------------------------
|
|
@@ -104,6 +105,7 @@ function createEventToChunkStream(sideChannel: SideChannelCallbacks): TransformS
|
|
| 104 |
endTextPart(controller);
|
| 105 |
controller.enqueue({ type: 'finish-step' });
|
| 106 |
controller.enqueue({ type: 'finish', finishReason: 'stop' });
|
|
|
|
| 107 |
sideChannel.onProcessingDone();
|
| 108 |
break;
|
| 109 |
|
|
@@ -234,6 +236,9 @@ function createEventToChunkStream(sideChannel: SideChannelCallbacks): TransformS
|
|
| 234 |
if (state === 'rejected' || state === 'abandoned') {
|
| 235 |
controller.enqueue({ type: 'tool-output-denied', toolCallId: tcId });
|
| 236 |
}
|
|
|
|
|
|
|
|
|
|
| 237 |
break;
|
| 238 |
}
|
| 239 |
|
|
|
|
| 31 |
onToolOutputPanel: (tool: string, toolCallId: string, output: string, success: boolean) => void;
|
| 32 |
onStreaming: () => void;
|
| 33 |
onToolRunning: (toolName: string, description?: string) => void;
|
| 34 |
+
onInterrupted: () => void;
|
| 35 |
}
|
| 36 |
|
| 37 |
// ---------------------------------------------------------------------------
|
|
|
|
| 105 |
endTextPart(controller);
|
| 106 |
controller.enqueue({ type: 'finish-step' });
|
| 107 |
controller.enqueue({ type: 'finish', finishReason: 'stop' });
|
| 108 |
+
sideChannel.onInterrupted();
|
| 109 |
sideChannel.onProcessingDone();
|
| 110 |
break;
|
| 111 |
|
|
|
|
| 236 |
if (state === 'rejected' || state === 'abandoned') {
|
| 237 |
controller.enqueue({ type: 'tool-output-denied', toolCallId: tcId });
|
| 238 |
}
|
| 239 |
+
if (state === 'cancelled') {
|
| 240 |
+
controller.enqueue({ type: 'tool-output-error', toolCallId: tcId, errorText: 'Cancelled by user', dynamic: true });
|
| 241 |
+
}
|
| 242 |
break;
|
| 243 |
}
|
| 244 |
|