akseljoonas HF Staff Claude Opus 4.6 commited on
Commit
d2c1b12
·
1 Parent(s): 0cee198

feat: render cancelled tool state in UI after user interrupt

Browse files

Send 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 state={
600
- (tool.toolName === 'hf_jobs' && jobMeta.jobStatus && ['ERROR', 'FAILED', 'CANCELLED'].includes(jobMeta.jobStatus) && displayState === 'output-available')
601
- ? 'output-error'
602
- : displayState as ToolPartState
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: displayState === 'output-error' ? 'rgba(224,90,79,0.12)'
 
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={stop}
103
  isProcessing={busy}
104
  disabled={!isConnected || activityStatus.type === 'waiting-approval'}
105
- placeholder={activityStatus.type === 'waiting-approval' ? 'Approve or reject pending tools first...' : undefined}
 
 
 
 
 
 
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 (abort SSE stream + interrupt backend agent loop) ---------------
590
  const stop = useCallback(() => {
591
- chat.stop();
 
 
 
592
  apiFetch(`/api/interrupt/${sessionId}`, { method: 'POST' }).catch(() => {});
593
- }, [sessionId, chat]);
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