Skip to content

Commit c652501

Browse files
cristipufuclaude
andcommitted
feat: add HITL interrupt support for chat mode
Add Human-In-The-Loop support to the dev server's chat UI. When a chat agent suspends with API triggers (e.g. tool call confirmations), the new WebChatBridge broadcasts interrupts to the frontend via WebSocket and blocks until the user responds. Backend: WebChatBridge (UiPathChatProtocol), InterruptData model, WS protocol events, RunService integration with UiPathChatRuntime. Frontend: ChatInterrupt component with tool call confirmation (Approve/Reject) and generic interrupt (text input) variants. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d74a4c5 commit c652501

21 files changed

Lines changed: 576 additions & 77 deletions

src/uipath/dev/models/data.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,17 @@ class ChatData:
5050
run_id: str
5151
event: UiPathConversationMessageEvent | None = None
5252
message: UiPathConversationMessage | None = None
53+
54+
55+
@dataclass
56+
class InterruptData:
57+
"""Plain data class for HITL interrupt events."""
58+
59+
run_id: str
60+
interrupt_id: str
61+
interrupt_type: str # "tool_call_confirmation" | "generic"
62+
tool_call_id: str | None = None
63+
tool_name: str | None = None
64+
input_schema: Any | None = None
65+
input_value: Any | None = None
66+
content: Any | None = None

src/uipath/dev/server/__init__.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,13 @@
1717
from uipath.core.tracing import UiPathTraceManager
1818
from uipath.runtime import UiPathRuntimeFactoryProtocol
1919

20-
from uipath.dev.models.data import ChatData, LogData, StateData, TraceData
20+
from uipath.dev.models.data import (
21+
ChatData,
22+
InterruptData,
23+
LogData,
24+
StateData,
25+
TraceData,
26+
)
2127
from uipath.dev.models.execution import ExecutionRun
2228
from uipath.dev.server.debug_bridge import WebDebugBridge
2329
from uipath.dev.services.run_service import RunService
@@ -75,6 +81,7 @@ def __init__(
7581
on_trace=self._on_trace,
7682
on_chat=self._on_chat,
7783
on_state=self._on_state,
84+
on_interrupt=self._on_interrupt,
7885
debug_bridge_factory=lambda mode: WebDebugBridge(mode=mode),
7986
)
8087

@@ -215,6 +222,10 @@ def _on_chat(self, chat_data: ChatData) -> None:
215222
"""Broadcast chat message to subscribed WebSocket clients."""
216223
self.connection_manager.broadcast_chat(chat_data)
217224

225+
def _on_interrupt(self, interrupt_data: InterruptData) -> None:
226+
"""Broadcast chat interrupt to subscribed WebSocket clients."""
227+
self.connection_manager.broadcast_interrupt(interrupt_data)
228+
218229
def _on_state(self, state_data: StateData) -> None:
219230
"""Broadcast state transition to subscribed WebSocket clients."""
220231
self.connection_manager.broadcast_state(state_data)

src/uipath/dev/server/frontend/src/api/websocket.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ export class WsClient {
9696
this.send("chat.message", { run_id: runId, text });
9797
}
9898

99+
sendInterruptResponse(runId: string, data: Record<string, unknown>): void {
100+
this.send("chat.interrupt_response", { run_id: runId, data });
101+
}
102+
99103
debugStep(runId: string): void {
100104
this.send("debug.step", { run_id: runId });
101105
}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { useState } from "react";
2+
import type { InterruptEvent } from "../../types/run";
3+
4+
interface Props {
5+
interrupt: InterruptEvent;
6+
onRespond: (data: Record<string, unknown>) => void;
7+
}
8+
9+
export default function ChatInterrupt({ interrupt, onRespond }: Props) {
10+
const [responseText, setResponseText] = useState("");
11+
12+
if (interrupt.interrupt_type === "tool_call_confirmation") {
13+
return (
14+
<div
15+
className="mx-3 my-2 rounded-lg overflow-hidden"
16+
style={{ border: "1px solid color-mix(in srgb, var(--warning) 40%, var(--border))" }}
17+
>
18+
<div
19+
className="px-3 py-2 flex items-center gap-2"
20+
style={{
21+
background: "color-mix(in srgb, var(--warning) 10%, var(--bg-secondary))",
22+
}}
23+
>
24+
<span
25+
className="text-[10px] uppercase tracking-wider font-semibold"
26+
style={{ color: "var(--warning)" }}
27+
>
28+
Action Required
29+
</span>
30+
{interrupt.tool_name && (
31+
<span
32+
className="text-[10px] font-mono px-1.5 py-0.5 rounded"
33+
style={{
34+
background: "color-mix(in srgb, var(--warning) 15%, var(--bg-secondary))",
35+
color: "var(--text-primary)",
36+
}}
37+
>
38+
{interrupt.tool_name}
39+
</span>
40+
)}
41+
</div>
42+
{interrupt.input_value != null && (
43+
<pre
44+
className="px-3 py-2 text-[11px] font-mono whitespace-pre-wrap break-words overflow-y-auto"
45+
style={{
46+
background: "var(--bg-secondary)",
47+
color: "var(--text-secondary)",
48+
maxHeight: 200,
49+
}}
50+
>
51+
{typeof interrupt.input_value === "string"
52+
? interrupt.input_value
53+
: JSON.stringify(interrupt.input_value, null, 2)}
54+
</pre>
55+
)}
56+
<div
57+
className="flex items-center gap-2 px-3 py-2"
58+
style={{
59+
background: "var(--bg-secondary)",
60+
borderTop: "1px solid var(--border)",
61+
}}
62+
>
63+
<button
64+
onClick={() => onRespond({ approved: true })}
65+
className="text-[10px] uppercase tracking-wider font-semibold px-3 py-1 rounded cursor-pointer transition-colors"
66+
style={{
67+
background: "color-mix(in srgb, var(--success) 15%, var(--bg-secondary))",
68+
color: "var(--success)",
69+
border: "1px solid color-mix(in srgb, var(--success) 30%, var(--border))",
70+
}}
71+
onMouseEnter={(e) => {
72+
e.currentTarget.style.background = "color-mix(in srgb, var(--success) 25%, var(--bg-secondary))";
73+
}}
74+
onMouseLeave={(e) => {
75+
e.currentTarget.style.background = "color-mix(in srgb, var(--success) 15%, var(--bg-secondary))";
76+
}}
77+
>
78+
Approve
79+
</button>
80+
<button
81+
onClick={() => onRespond({ approved: false })}
82+
className="text-[10px] uppercase tracking-wider font-semibold px-3 py-1 rounded cursor-pointer transition-colors"
83+
style={{
84+
background: "color-mix(in srgb, var(--error) 15%, var(--bg-secondary))",
85+
color: "var(--error)",
86+
border: "1px solid color-mix(in srgb, var(--error) 30%, var(--border))",
87+
}}
88+
onMouseEnter={(e) => {
89+
e.currentTarget.style.background = "color-mix(in srgb, var(--error) 25%, var(--bg-secondary))";
90+
}}
91+
onMouseLeave={(e) => {
92+
e.currentTarget.style.background = "color-mix(in srgb, var(--error) 15%, var(--bg-secondary))";
93+
}}
94+
>
95+
Reject
96+
</button>
97+
</div>
98+
</div>
99+
);
100+
}
101+
102+
// Generic interrupt
103+
return (
104+
<div
105+
className="mx-3 my-2 rounded-lg overflow-hidden"
106+
style={{ border: "1px solid color-mix(in srgb, var(--accent) 40%, var(--border))" }}
107+
>
108+
<div
109+
className="px-3 py-2"
110+
style={{
111+
background: "color-mix(in srgb, var(--accent) 10%, var(--bg-secondary))",
112+
}}
113+
>
114+
<span
115+
className="text-[10px] uppercase tracking-wider font-semibold"
116+
style={{ color: "var(--accent)" }}
117+
>
118+
Input Required
119+
</span>
120+
</div>
121+
{interrupt.content != null && (
122+
<div
123+
className="px-3 py-2 text-xs"
124+
style={{
125+
background: "var(--bg-secondary)",
126+
color: "var(--text-secondary)",
127+
}}
128+
>
129+
{typeof interrupt.content === "string"
130+
? interrupt.content
131+
: JSON.stringify(interrupt.content, null, 2)}
132+
</div>
133+
)}
134+
<div
135+
className="flex items-center gap-2 px-3 py-2"
136+
style={{
137+
background: "var(--bg-secondary)",
138+
borderTop: "1px solid var(--border)",
139+
}}
140+
>
141+
<input
142+
value={responseText}
143+
onChange={(e) => setResponseText(e.target.value)}
144+
onKeyDown={(e) => {
145+
if (e.key === "Enter" && !e.shiftKey && responseText.trim()) {
146+
e.preventDefault();
147+
onRespond({ response: responseText.trim() });
148+
}
149+
}}
150+
placeholder="Type your response..."
151+
className="flex-1 bg-transparent text-xs py-1 focus:outline-none placeholder:text-[var(--text-muted)]"
152+
style={{ color: "var(--text-primary)" }}
153+
/>
154+
<button
155+
onClick={() => {
156+
if (responseText.trim()) {
157+
onRespond({ response: responseText.trim() });
158+
}
159+
}}
160+
disabled={!responseText.trim()}
161+
className="text-[10px] uppercase tracking-wider font-semibold px-2 py-1 rounded transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
162+
style={{
163+
color: responseText.trim() ? "var(--accent)" : "var(--text-muted)",
164+
background: "transparent",
165+
}}
166+
>
167+
Send
168+
</button>
169+
</div>
170+
</div>
171+
);
172+
}

src/uipath/dev/server/frontend/src/components/chat/ChatPanel.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { WsClient } from "../../api/websocket";
33
import { useRunStore } from "../../store/useRunStore";
44
import ChatMessage from "./ChatMessage";
55
import ChatInput from "./ChatInput";
6+
import ChatInterrupt from "./ChatInterrupt";
67

78
interface ChatMsg {
89
message_id: string;
@@ -23,6 +24,8 @@ export default function ChatPanel({ messages, runId, runStatus, ws }: Props) {
2324
const stickToBottom = useRef(true);
2425
const addLocalChatMessage = useRunStore((s) => s.addLocalChatMessage);
2526
const setFocusedSpan = useRunStore((s) => s.setFocusedSpan);
27+
const interrupt = useRunStore((s) => s.activeInterrupt[runId] ?? null);
28+
const setActiveInterrupt = useRunStore((s) => s.setActiveInterrupt);
2629

2730
// Precompute per-tool-call occurrence indices across all messages
2831
const toolCallIndicesMap = useMemo(() => {
@@ -71,7 +74,13 @@ export default function ChatPanel({ messages, runId, runStatus, ws }: Props) {
7174
ws.sendChatMessage(runId, text);
7275
};
7376

74-
const isDisabled = runStatus === "running";
77+
const handleInterruptResponse = (data: Record<string, unknown>) => {
78+
stickToBottom.current = true;
79+
ws.sendInterruptResponse(runId, data);
80+
setActiveInterrupt(runId, null);
81+
};
82+
83+
const isDisabled = runStatus === "running" || !!interrupt;
7584

7685
return (
7786
<div className="flex flex-col h-full">
@@ -94,6 +103,12 @@ export default function ChatPanel({ messages, runId, runStatus, ws }: Props) {
94103
onToolCallClick={(name, idx) => setFocusedSpan({ name, index: idx })}
95104
/>
96105
))}
106+
{interrupt && (
107+
<ChatInterrupt
108+
interrupt={interrupt}
109+
onRespond={handleInterruptResponse}
110+
/>
111+
)}
97112
</div>
98113
{showScrollTop && (
99114
<button
@@ -111,7 +126,7 @@ export default function ChatPanel({ messages, runId, runStatus, ws }: Props) {
111126
<ChatInput
112127
onSend={handleSend}
113128
disabled={isDisabled}
114-
placeholder={isDisabled ? "Waiting for response..." : "Message..."}
129+
placeholder={interrupt ? "Respond to the interrupt above..." : isDisabled ? "Waiting for response..." : "Message..."}
115130
/>
116131
</div>
117132
);

src/uipath/dev/server/frontend/src/components/runs/RunDetailsPanel.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@ export default function RunDetailsPanel({ run, ws }: Props) {
124124
{ id: "logs", label: "Logs", count: logs.length },
125125
];
126126

127+
const interrupt = useRunStore((s) => s.activeInterrupt[run.id] ?? null);
128+
127129
// Status indicator for the tab bar
128130
const statusIndicator =
129131
run.status === "running" ? (
@@ -136,14 +138,24 @@ export default function RunDetailsPanel({ run, ws }: Props) {
136138
>
137139
{isChatMode ? "Thinking..." : "Running..."}
138140
</span>
141+
) : isChatMode && run.status === "suspended" && interrupt ? (
142+
<span
143+
className="ml-auto text-[10px] px-2 py-0.5 rounded-full shrink-0"
144+
style={{
145+
background: "color-mix(in srgb, var(--warning) 15%, var(--bg-secondary))",
146+
color: "var(--warning)",
147+
}}
148+
>
149+
Action Required
150+
</span>
139151
) : null;
140152

141153
return (
142154
<div ref={outerRef} className="flex h-full">
143155
{/* Main content: graph + trace tree */}
144156
<div ref={containerRef} className="flex flex-col flex-1 min-w-0">
145157
{/* Debug controls */}
146-
{(run.mode === "debug" || run.status === "suspended" || (bpMap && Object.keys(bpMap).length > 0)) && (
158+
{(run.mode === "debug" || (run.status === "suspended" && !interrupt) || (bpMap && Object.keys(bpMap).length > 0)) && (
147159
<DebugControls runId={run.id} status={run.status} ws={ws} breakpointNode={run.breakpoint_node} />
148160
)}
149161
{/* Graph panel — resizable */}

src/uipath/dev/server/frontend/src/store/useRunStore.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { create } from "zustand";
2-
import type { RunSummary, TraceSpan, LogEntry } from "../types/run";
2+
import type { RunSummary, TraceSpan, LogEntry, InterruptEvent } from "../types/run";
33
import type { GraphData } from "../types/graph";
44

55
interface ChatMsg {
@@ -47,6 +47,9 @@ interface RunStore {
4747
focusedSpan: { name: string; index: number } | null;
4848
setFocusedSpan: (span: { name: string; index: number } | null) => void;
4949

50+
activeInterrupt: Record<string, InterruptEvent | null>;
51+
setActiveInterrupt: (runId: string, interrupt: InterruptEvent | null) => void;
52+
5053
reloadPending: boolean;
5154
setReloadPending: (val: boolean) => void;
5255

@@ -92,6 +95,11 @@ export const useRunStore = create<RunStore>((set) => ({
9295
const { [run.id]: _, ...rest } = state.activeNodes;
9396
result.activeNodes = rest;
9497
}
98+
// Clear active interrupt when status changes away from suspended
99+
if (run.status !== "suspended" && state.activeInterrupt[run.id]) {
100+
const { [run.id]: _, ...rest } = state.activeInterrupt;
101+
result.activeInterrupt = rest;
102+
}
95103
return result;
96104
}),
97105

@@ -241,6 +249,12 @@ export const useRunStore = create<RunStore>((set) => ({
241249
focusedSpan: null,
242250
setFocusedSpan: (span) => set({ focusedSpan: span }),
243251

252+
activeInterrupt: {},
253+
setActiveInterrupt: (runId, interrupt) =>
254+
set((state) => ({
255+
activeInterrupt: { ...state.activeInterrupt, [runId]: interrupt },
256+
})),
257+
244258
reloadPending: false,
245259
setReloadPending: (val) => set({ reloadPending: val }),
246260

0 commit comments

Comments
 (0)