From 05c424da576c83b19732c2502cac03c02a7efc22 Mon Sep 17 00:00:00 2001 From: Josh Park <50765702+JoshParkSJ@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:44:24 -0400 Subject: [PATCH] =?UTF-8?q?wip:=20cas=20tool-confirmation=20migration=20(e?= =?UTF-8?q?vent-based=20flow)=20=E2=80=94=20reference?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates the dev console from the old interrupt-based tool-confirmation flow to the event-based flow that landed in uipath-python#1558, uipath-langchain-python#703, and uipath-agents-python#420. Status: closed as a reference. Approve flow E2E-verified; reject path fixes shipped late and were not E2E-verified before scope was reset. --- demo/mock_support_runtime.py | 83 ++- pyproject.toml | 4 +- src/uipath/dev/models/chat.py | 2 - src/uipath/dev/models/data.py | 14 - src/uipath/dev/server/__init__.py | 6 - src/uipath/dev/server/frontend/src/App.tsx | 7 +- .../dev/server/frontend/src/api/websocket.ts | 14 +- .../src/components/chat/ChatInterrupt.tsx | 547 ------------------ .../src/components/chat/ChatMessage.tsx | 193 +++++- .../src/components/chat/ChatPanel.tsx | 47 +- .../src/components/runs/RunDetailsPanel.tsx | 14 +- .../server/frontend/src/store/useRunStore.ts | 96 ++- .../server/frontend/src/store/useWebSocket.ts | 11 +- .../dev/server/frontend/src/types/run.ts | 20 +- .../dev/server/frontend/src/types/ws.ts | 3 +- .../dev/server/frontend/tsconfig.tsbuildinfo | 2 +- src/uipath/dev/server/serializers.py | 21 - ...anel-DJtePF36.js => ChatPanel-CjDEtK4O.js} | 46 +- .../server/static/assets/index-Bo7O-d4B.css | 32 + .../server/static/assets/index-CIbY4UMG.css | 32 - .../{index-C0mGt6tw.js => index-D5khwkcK.js} | 44 +- src/uipath/dev/server/static/index.html | 28 +- src/uipath/dev/server/ws/handler.py | 22 +- src/uipath/dev/server/ws/manager.py | 9 - src/uipath/dev/server/ws/protocol.py | 3 +- src/uipath/dev/services/chat_bridge.py | 64 +- src/uipath/dev/services/run_service.py | 81 +-- tests/test_chat_bridge.py | 84 +++ uv.lock | 28 +- 29 files changed, 644 insertions(+), 913 deletions(-) delete mode 100644 src/uipath/dev/server/frontend/src/components/chat/ChatInterrupt.tsx rename src/uipath/dev/server/static/assets/{ChatPanel-DJtePF36.js => ChatPanel-CjDEtK4O.js} (62%) create mode 100644 src/uipath/dev/server/static/assets/index-Bo7O-d4B.css delete mode 100644 src/uipath/dev/server/static/assets/index-CIbY4UMG.css rename src/uipath/dev/server/static/assets/{index-C0mGt6tw.js => index-D5khwkcK.js} (67%) create mode 100644 tests/test_chat_bridge.py diff --git a/demo/mock_support_runtime.py b/demo/mock_support_runtime.py index 138657e..b9b166d 100644 --- a/demo/mock_support_runtime.py +++ b/demo/mock_support_runtime.py @@ -15,9 +15,6 @@ UiPathConversationContentPartEvent, UiPathConversationContentPartStartEvent, ) -from uipath.core.chat.interrupt import ( - UiPathConversationToolCallConfirmationValue, -) from uipath.core.chat.message import ( UiPathConversationMessageEndEvent, UiPathConversationMessageStartEvent, @@ -197,6 +194,8 @@ def __init__(self, entrypoint: str = ENTRYPOINT_SUPPORT_CHAT) -> None: self.tracer = trace.get_tracer("uipath.dev.mock.support-chat") self._suspended_turn: dict[str, Any] | None = None self._suspended_message_id: str | None = None + self._suspended_interrupt_id: str | None = None + self._suspended_call_id: str | None = None async def get_schema(self) -> UiPathRuntimeSchema: """Get the schema for the support chat runtime.""" @@ -505,8 +504,49 @@ async def stream( # --- Resume after tool approval --- if is_resuming and self._suspended_turn is not None: turn = self._suspended_turn + message_id = self._suspended_message_id or "" + interrupt_id = self._suspended_interrupt_id or "" + call_id = self._suspended_call_id or "" self._suspended_turn = None self._suspended_message_id = None + self._suspended_interrupt_id = None + self._suspended_call_id = None + + # ``current_input`` on resume is ``{interrupt_id: {approved, input}}`` + # — extract the user's decision. + confirmation = ( + input.get(interrupt_id, {}) if isinstance(input, dict) else {} + ) + approved = bool(confirmation.get("approved", False)) + approval_tool = turn["approval_tool"] + + yield UiPathRuntimeMessageEvent( + payload=UiPathConversationMessageEvent( + message_id=message_id, + tool_call=UiPathConversationToolCallEvent( + tool_call_id=call_id, + end=UiPathConversationToolCallEndEvent( + timestamp=datetime.now().isoformat(), + output=approval_tool["output"] + if approved + else "Cancelled by user.", + cancelled=not approved, + ), + ), + ), + ) + + if not approved: + # User rejected — skip phase 2 and yield a SUCCESSFUL result so + # UiPathChatRuntime exits its outer loop. Returning bare here + # would leave ``execution_completed = False`` and trigger + # another ``delegate.stream`` iteration, advancing to the next + # turn. + yield UiPathRuntimeResult( + output={"reply": "Cancelled by user."}, + status=UiPathRuntimeStatus.SUCCESSFUL, + ) + return resume_span = self.tracer.start_span( "support_chat.resume", @@ -639,35 +679,50 @@ async def stream( await asyncio.sleep(0.5) yield self._node_state("tools", C) - # --- Check if this turn needs tool approval --- + # --- Approval flow: emit startToolCall(requireConfirmation=true) + # so the UI renders the inline approve/reject panel, then suspend + # so UiPathChatRuntime awaits ``bridge.wait_for_resume()``. The + # bridge unblocks when the user clicks approve/reject and the + # confirmation flows through ``run_service.confirm_tool_call``. if needs_approval: approval_tool = turn["approval_tool"] + approval_call_id = f"call_{uuid4().hex[:12]}" interrupt_id = str(uuid4()) - tool_call_id = f"call_{uuid4().hex[:12]}" - # Store state for resume + yield UiPathRuntimeMessageEvent( + payload=UiPathConversationMessageEvent( + message_id=message_id, + tool_call=UiPathConversationToolCallEvent( + tool_call_id=approval_call_id, + start=UiPathConversationToolCallStartEvent( + tool_name=approval_tool["name"], + timestamp=datetime.now().isoformat(), + input=approval_tool["input"], + require_confirmation=True, + input_schema=approval_tool["input_schema"], + ), + ), + ), + ) + self._suspended_turn = turn self._suspended_message_id = message_id + self._suspended_interrupt_id = interrupt_id + self._suspended_call_id = approval_call_id yield UiPathRuntimeResult( status=UiPathRuntimeStatus.SUSPENDED, - output={"paused_for_approval": approval_tool["name"]}, + output={"awaiting_confirmation": approval_tool["name"]}, triggers=[ UiPathResumeTrigger( interrupt_id=interrupt_id, trigger_type=UiPathResumeTriggerType.API, - payload=UiPathConversationToolCallConfirmationValue( - tool_call_id=tool_call_id, - tool_name=approval_tool["name"], - input_schema=approval_tool["input_schema"], - input_value=approval_tool["input"], - ), ), ], ) return - # --- No approval needed — continue with phase 2 inline --- + # --- Continue with phase 2 inline --- # --- Middleware: Summarization (second pass with tool results) --- yield self._node_state("SummarizationMiddleware.before_model", S) diff --git a/pyproject.toml b/pyproject.toml index 1286fbc..98e123c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-dev" -version = "0.0.78" +version = "0.0.79" description = "UiPath Developer Console" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" @@ -10,7 +10,7 @@ dependencies = [ "pyperclip>=1.11.0, <2.0.0", "fastapi>=0.128.8", "uvicorn[standard]>=0.40.0", - "uipath>=2.10.0, <2.11.0", + "uipath>=2.10.57, <2.11.0", "aiosqlite>=0.20.0", "pywinpty>=2.0.0; sys_platform == 'win32'", "mcp[cli]>=1.0.0", diff --git a/src/uipath/dev/models/chat.py b/src/uipath/dev/models/chat.py index ef14139..920fb96 100644 --- a/src/uipath/dev/models/chat.py +++ b/src/uipath/dev/models/chat.py @@ -40,7 +40,6 @@ def add( role=self.get_role(event), content_parts=[], tool_calls=[], - interrupts=[], created_at=self.get_timestamp(event), updated_at=self.get_timestamp(event), ) @@ -205,7 +204,6 @@ def get_user_message(user_text: str) -> UiPathConversationMessage: ) ], tool_calls=[], - interrupts=[], role="user", ) diff --git a/src/uipath/dev/models/data.py b/src/uipath/dev/models/data.py index f20f22b..ef30516 100644 --- a/src/uipath/dev/models/data.py +++ b/src/uipath/dev/models/data.py @@ -51,17 +51,3 @@ class ChatData: run_id: str event: UiPathConversationMessageEvent | None = None message: UiPathConversationMessage | None = None - - -@dataclass -class InterruptData: - """Plain data class for HITL interrupt events.""" - - run_id: str - interrupt_id: str - interrupt_type: str # "tool_call_confirmation" | "generic" - tool_call_id: str | None = None - tool_name: str | None = None - input_schema: Any | None = None - input_value: Any | None = None - content: Any | None = None diff --git a/src/uipath/dev/server/__init__.py b/src/uipath/dev/server/__init__.py index 80b3a17..af3d30d 100644 --- a/src/uipath/dev/server/__init__.py +++ b/src/uipath/dev/server/__init__.py @@ -20,7 +20,6 @@ from uipath.dev.models.data import ( ChatData, - InterruptData, LogData, StateData, TraceData, @@ -85,7 +84,6 @@ def __init__( on_trace=self._on_trace, on_chat=self._on_chat, on_state=self._on_state, - on_interrupt=self._on_interrupt, debug_bridge_factory=lambda mode: WebDebugBridge(mode=mode), on_run_removed=self.connection_manager.remove_run_subscriptions, ) @@ -303,10 +301,6 @@ def _on_chat(self, chat_data: ChatData) -> None: """Broadcast chat message to subscribed WebSocket clients.""" self.connection_manager.broadcast_chat(chat_data) - def _on_interrupt(self, interrupt_data: InterruptData) -> None: - """Broadcast chat interrupt to subscribed WebSocket clients.""" - self.connection_manager.broadcast_interrupt(interrupt_data) - def _on_state(self, state_data: StateData) -> None: """Broadcast state transition to subscribed WebSocket clients.""" self.connection_manager.broadcast_state(state_data) diff --git a/src/uipath/dev/server/frontend/src/App.tsx b/src/uipath/dev/server/frontend/src/App.tsx index cf21e22..d05590b 100644 --- a/src/uipath/dev/server/frontend/src/App.tsx +++ b/src/uipath/dev/server/frontend/src/App.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { useRunStore } from "./store/useRunStore"; +import { useRunStore, projectToolCall } from "./store/useRunStore"; import { useAuthStore } from "./store/useAuthStore"; import { useConfigStore } from "./store/useConfigStore"; import { useWebSocket } from "./store/useWebSocket"; @@ -156,10 +156,7 @@ export default function App() { .join("\n") .trim() ?? "", tool_calls: toolCalls.length > 0 - ? toolCalls.map((tc) => ({ - name: (tc.name as string) ?? "", - has_result: !!tc.result, - })) + ? toolCalls.map(projectToolCall) : undefined, }; }); diff --git a/src/uipath/dev/server/frontend/src/api/websocket.ts b/src/uipath/dev/server/frontend/src/api/websocket.ts index 032c1ed..f7510c6 100644 --- a/src/uipath/dev/server/frontend/src/api/websocket.ts +++ b/src/uipath/dev/server/frontend/src/api/websocket.ts @@ -104,8 +104,18 @@ export class WsClient { this.send("chat.message", { run_id: runId, text }); } - sendInterruptResponse(runId: string, data: Record): void { - this.send("chat.interrupt_response", { run_id: runId, data }); + sendConfirmToolCall( + runId: string, + toolCallId: string, + approved: boolean, + input?: unknown, + ): void { + this.send("chat.confirm_tool_call", { + run_id: runId, + tool_call_id: toolCallId, + approved, + input, + }); } debugStep(runId: string): void { diff --git a/src/uipath/dev/server/frontend/src/components/chat/ChatInterrupt.tsx b/src/uipath/dev/server/frontend/src/components/chat/ChatInterrupt.tsx deleted file mode 100644 index 5299b6d..0000000 --- a/src/uipath/dev/server/frontend/src/components/chat/ChatInterrupt.tsx +++ /dev/null @@ -1,547 +0,0 @@ -import { useState, useCallback } from "react"; -import type { InterruptEvent } from "../../types/run"; - -interface Props { - interrupt: InterruptEvent; - onRespond: (data: Record) => void; -} - -/* ── JSON Schema helpers ─────────────────────────────────── */ - -interface SchemaProperty { - type?: string; - description?: string; - enum?: unknown[]; -} - -interface JsonSchema { - properties?: Record; -} - -function hasProperties( - schema: unknown, -): schema is JsonSchema & { properties: Record } { - if (typeof schema !== "object" || schema === null) return false; - const s = schema as Record; - return ( - typeof s.properties === "object" && - s.properties !== null && - Object.keys(s.properties as object).length > 0 - ); -} - -/* ── Per-field form input ────────────────────────────────── */ - -const inputStyle = { - color: "var(--text-primary)", - border: "1px solid var(--border)", - background: "var(--bg-primary)", -}; -const inputClass = - "w-full text-[11px] font-mono py-1 px-2 rounded focus:outline-none"; - -function FormField({ - name, - prop, - value, - onChange, -}: { - name: string; - prop: SchemaProperty; - value: unknown; - onChange: (v: unknown) => void; -}) { - const label = ( - - ); - - if (prop.enum && Array.isArray(prop.enum)) { - return ( -
- {label} - -
- ); - } - - if (prop.type === "boolean") { - return ( -
- {label} - -
- ); - } - - if (prop.type === "number" || prop.type === "integer") { - return ( -
- {label} - - onChange(e.target.value === "" ? null : Number(e.target.value)) - } - step={prop.type === "integer" ? 1 : "any"} - className={inputClass} - style={inputStyle} - /> -
- ); - } - - if (prop.type === "object" || prop.type === "array") { - const str = - typeof value === "string" - ? value - : JSON.stringify(value ?? null, null, 2); - return ( -
- {label} -