From 7962754e12a1c10b4c5ed99c052342679fa836a2 Mon Sep 17 00:00:00 2001 From: Cristian Pufu Date: Sat, 14 Feb 2026 13:20:07 +0200 Subject: [PATCH 1/5] feat: style server banner with Rich colors Use Rich console for the startup ASCII art banner - bold orange for the art, cyan for the server URL, and clickable docs link. Co-Authored-By: Claude Opus 4.6 --- src/uipath/dev/server/__init__.py | 40 +++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/src/uipath/dev/server/__init__.py b/src/uipath/dev/server/__init__.py index 3974936..31f8aad 100644 --- a/src/uipath/dev/server/__init__.py +++ b/src/uipath/dev/server/__init__.py @@ -259,6 +259,11 @@ def _print_banner(base_url: str) -> None: """Print a welcome banner to the console.""" import sys + from rich.console import Console + from rich.text import Text + + console = Console() + # Use emojis only if stdout supports unicode (not Windows cp1252) try: "\U0001f916".encode(sys.stdout.encoding or "utf-8") @@ -266,20 +271,29 @@ def _print_banner(base_url: str) -> None: except (UnicodeEncodeError, LookupError): server_icon, docs_icon = ">>", ">>" - banner = ( - "\n" - " _ _ _ ____ _ _ ____\n" - "| | | (_) _ \\ __ _| |_| |__ | _ \\ _____ __\n" - "| | | | | |_) / _` | __| '_ \\ | | | |/ _ \\ \\ / /\n" - "| |_| | | __/ (_| | |_| | | | | |_| | __/\\ V /\n" - " \\___/|_|_| \\__,_|\\__|_| |_| |____/ \\___| \\_/\n" - "\n" - f" {server_icon} Server: {base_url}\n" - f" {docs_icon} Docs: https://uipath.github.io/uipath-python/\n" - "\n" - " This server is designed for development and testing.\n" + art_lines = [ + " _ _ _ ____ _ _ ____", + "| | | (_) _ \\ __ _| |_| |__ | _ \\ _____ __", + "| | | | | |_) / _` | __| '_ \\ | | | |/ _ \\ \\ / /", + "| |_| | | __/ (_| | |_| | | | | |_| | __/\\ V /", + " \\___/|_|_| \\__,_|\\__|_| |_| |____/ \\___| \\_/", + ] + + console.print() + for line in art_lines: + styled = Text(line) + styled.stylize("bold orange1") + console.print(styled) + console.print() + + console.print(f" {server_icon} Server: [bold cyan]{base_url}[/bold cyan]") + console.print( + f" {docs_icon} Docs: [link=https://uipath.github.io/uipath-python/]" + "https://uipath.github.io/uipath-python/[/link]" ) - print(banner) + console.print() + console.print(" [dim]This server is designed for development and testing.[/dim]") + console.print() def _deferred_open_browser(self) -> None: """Open the browser after a short delay to let uvicorn bind.""" From d74a4c50b4a68bc1bd49a3a64e94c273fe6870a0 Mon Sep 17 00:00:00 2001 From: Cristian Pufu Date: Sat, 14 Feb 2026 13:30:03 +0200 Subject: [PATCH 2/5] refactor: remove server optional-dependencies, make fastapi/uvicorn core deps Move fastapi and uvicorn from optional [server] extras into core dependencies. Remove HAS_EXTRAS guards, TYPE_CHECKING import hack, and associated tests. Co-Authored-By: Claude Opus 4.6 --- pyproject.toml | 8 +--- src/uipath/dev/__mock_server__.py | 2 +- src/uipath/dev/server/__init__.py | 23 ++--------- src/uipath/dev/server/app.py | 6 +-- tests/e2e/conftest.py | 2 +- tests/test_server.py | 67 ------------------------------- uv.lock | 11 ++--- 7 files changed, 13 insertions(+), 106 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f428ba7..85bb5a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,8 @@ dependencies = [ "uipath-runtime>=0.8.2, <0.9.0", "textual>=7.5.0, <8.0.0", "pyperclip>=1.11.0, <2.0.0", + "fastapi>=0.128.8", + "uvicorn[standard]>=0.40.0", ] classifiers = [ "Intended Audience :: Developers", @@ -21,12 +23,6 @@ maintainers = [ { name = "Cristian Pufu", email = "cristian.pufu@uipath.com" }, ] -[project.optional-dependencies] -server = [ - "fastapi>=0.128.8", - "uvicorn[standard]>=0.40.0", -] - [project.urls] Homepage = "https://uipath.com" Repository = "https://github.com/UiPath/uipath-dev-python" diff --git a/src/uipath/dev/__mock_server__.py b/src/uipath/dev/__mock_server__.py index 476033e..c18c5d3 100644 --- a/src/uipath/dev/__mock_server__.py +++ b/src/uipath/dev/__mock_server__.py @@ -32,7 +32,7 @@ def main(): server.run() except ImportError as e: print(f"Required dependencies not available: {e}") - print("Install server dependencies: pip install uipath-dev[server]") + print("Install server dependencies: pip install uipath-dev") if __name__ == "__main__": diff --git a/src/uipath/dev/server/__init__.py b/src/uipath/dev/server/__init__.py index 31f8aad..1e98f74 100644 --- a/src/uipath/dev/server/__init__.py +++ b/src/uipath/dev/server/__init__.py @@ -13,6 +13,7 @@ from collections.abc import Callable from typing import Any +import uvicorn from uipath.core.tracing import UiPathTraceManager from uipath.runtime import UiPathRuntimeFactoryProtocol @@ -23,18 +24,6 @@ logger = logging.getLogger(__name__) -try: - import fastapi # noqa: F401 - import uvicorn # noqa: F401 - - HAS_EXTRAS = True -except ModuleNotFoundError: - HAS_EXTRAS = False - -_MISSING_EXTRAS_MSG = ( - "Server extras are not installed. Install them with: pip install uipath-dev[server]" -) - class UiPathDeveloperServer: """Web server mode for the UiPath Developer Console. @@ -91,9 +80,6 @@ def __init__( def create_app(self) -> Any: """Create and return a FastAPI application.""" - if not HAS_EXTRAS: - raise ImportError(_MISSING_EXTRAS_MSG) - from uipath.dev.server.app import create_app return create_app(self) @@ -104,9 +90,6 @@ async def run_async(self) -> None: This is the main entry point — mirrors UiPathDeveloperConsole.run_async(). Blocks until the server is shut down (Ctrl-C / SIGINT). """ - if not HAS_EXTRAS: - raise ImportError(_MISSING_EXTRAS_MSG) - await self.run_service.apply_factory_settings() self.port = self._find_free_port(self.host, self.port) app = self.create_app() @@ -292,7 +275,9 @@ def _print_banner(base_url: str) -> None: "https://uipath.github.io/uipath-python/[/link]" ) console.print() - console.print(" [dim]This server is designed for development and testing.[/dim]") + console.print( + " [dim]This server is designed for development and testing.[/dim]" + ) console.print() def _deferred_open_browser(self) -> None: diff --git a/src/uipath/dev/server/app.py b/src/uipath/dev/server/app.py index 1e065ba..542582c 100644 --- a/src/uipath/dev/server/app.py +++ b/src/uipath/dev/server/app.py @@ -4,14 +4,12 @@ import logging from pathlib import Path -from typing import TYPE_CHECKING from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse, Response -if TYPE_CHECKING: - from uipath.dev.server import UiPathDeveloperServer +from uipath.dev.server import UiPathDeveloperServer logger = logging.getLogger(__name__) @@ -75,7 +73,7 @@ def _fallback_html() -> str: "The frontend source directory was not found. " "If you installed from PyPI, the pre-built static files should " "be included. Try reinstalling with " - "pip install uipath-dev[server]." + "pip install uipath-dev." ), ) diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 1f7487f..e913a9c 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -40,7 +40,7 @@ def live_server_url(): from uipath.dev.server import UiPathDeveloperServer except ImportError: - pytest.skip("server extras not installed (pip install uipath-dev[server])") + pytest.skip("server dependencies not installed (pip install uipath-dev)") factory = MockRuntimeFactory() trace_mgr = UiPathTraceManager() diff --git a/tests/test_server.py b/tests/test_server.py index 2bbf285..08854c4 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -3,8 +3,6 @@ from datetime import datetime from unittest.mock import MagicMock -import pytest - def test_data_models_import_without_textual(): """Data models should import without Textual dependency.""" @@ -150,68 +148,3 @@ def test_frontend_build_module(): assert callable(needs_build) assert callable(ensure_frontend_built) - - -def test_create_app_raises_when_extras_missing(): - """create_app() should raise ImportError when server extras are missing.""" - import uipath.dev.server as server_mod - - mock_server = MagicMock() - mock_server.run_service = MagicMock() - mock_server.connection_manager = MagicMock() - mock_server.runtime_factory = MagicMock() - mock_server.trace_manager = MagicMock() - - original = server_mod.HAS_EXTRAS - try: - server_mod.HAS_EXTRAS = False - with pytest.raises(ImportError, match="pip install uipath-dev\\[server\\]"): - mock_server.create_app = server_mod.UiPathDeveloperServer.create_app - mock_server.create_app(mock_server) - finally: - server_mod.HAS_EXTRAS = original - - -def test_run_async_raises_when_extras_missing(): - """run_async() should raise ImportError when server extras are missing. - - Runs the coroutine in a separate thread so this test still passes - when Playwright (which keeps its own event loop alive on the main - thread) runs in the same session. - """ - import asyncio - import threading - - import uipath.dev.server as server_mod - - mock_server = MagicMock() - mock_server.run_service = MagicMock() - mock_server.connection_manager = MagicMock() - mock_server.runtime_factory = MagicMock() - mock_server.trace_manager = MagicMock() - - captured: list[BaseException] = [] - - def _run(): - loop = asyncio.new_event_loop() - try: - loop.run_until_complete( - server_mod.UiPathDeveloperServer.run_async(mock_server) - ) - except BaseException as exc: - captured.append(exc) - finally: - loop.close() - - original = server_mod.HAS_EXTRAS - try: - server_mod.HAS_EXTRAS = False - t = threading.Thread(target=_run) - t.start() - t.join(timeout=5) - finally: - server_mod.HAS_EXTRAS = original - - assert len(captured) == 1 - assert isinstance(captured[0], ImportError) - assert "pip install uipath-dev[server]" in str(captured[0]) diff --git a/uv.lock b/uv.lock index 03949f1..7012731 100644 --- a/uv.lock +++ b/uv.lock @@ -1403,14 +1403,10 @@ name = "uipath-dev" version = "0.0.42" source = { editable = "." } dependencies = [ + { name = "fastapi" }, { name = "pyperclip" }, { name = "textual" }, { name = "uipath-runtime" }, -] - -[package.optional-dependencies] -server = [ - { name = "fastapi" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -1434,13 +1430,12 @@ dev = [ [package.metadata] requires-dist = [ - { name = "fastapi", marker = "extra == 'server'", specifier = ">=0.128.8" }, + { name = "fastapi", specifier = ">=0.128.8" }, { name = "pyperclip", specifier = ">=1.11.0,<2.0.0" }, { name = "textual", specifier = ">=7.5.0,<8.0.0" }, { name = "uipath-runtime", specifier = ">=0.8.2,<0.9.0" }, - { name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = ">=0.40.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.40.0" }, ] -provides-extras = ["server"] [package.metadata.requires-dev] dev = [ From c6525014b77b7ade150bf10a855c8088bca63682 Mon Sep 17 00:00:00 2001 From: Cristian Pufu Date: Sat, 14 Feb 2026 14:38:51 +0200 Subject: [PATCH 3/5] 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 --- src/uipath/dev/models/data.py | 14 ++ src/uipath/dev/server/__init__.py | 13 +- .../dev/server/frontend/src/api/websocket.ts | 4 + .../src/components/chat/ChatInterrupt.tsx | 172 ++++++++++++++++++ .../src/components/chat/ChatPanel.tsx | 19 +- .../src/components/runs/RunDetailsPanel.tsx | 14 +- .../server/frontend/src/store/useRunStore.ts | 16 +- .../server/frontend/src/store/useWebSocket.ts | 11 +- .../dev/server/frontend/src/types/run.ts | 11 ++ .../dev/server/frontend/src/types/ws.ts | 3 +- .../dev/server/frontend/tsconfig.tsbuildinfo | 2 +- src/uipath/dev/server/serializers.py | 28 ++- .../{index-DMo-FG3F.js => index-C71D5aNM.js} | 90 ++++----- .../server/static/assets/index-CQPdc1iX.css | 1 + .../server/static/assets/index-uNV7Cy8X.css | 1 - src/uipath/dev/server/static/index.html | 4 +- src/uipath/dev/server/ws/handler.py | 23 +++ src/uipath/dev/server/ws/manager.py | 16 +- src/uipath/dev/server/ws/protocol.py | 2 + src/uipath/dev/services/chat_bridge.py | 66 +++++++ src/uipath/dev/services/run_service.py | 143 +++++++++++++-- 21 files changed, 576 insertions(+), 77 deletions(-) create mode 100644 src/uipath/dev/server/frontend/src/components/chat/ChatInterrupt.tsx rename src/uipath/dev/server/static/assets/{index-DMo-FG3F.js => index-C71D5aNM.js} (81%) create mode 100644 src/uipath/dev/server/static/assets/index-CQPdc1iX.css delete mode 100644 src/uipath/dev/server/static/assets/index-uNV7Cy8X.css create mode 100644 src/uipath/dev/services/chat_bridge.py diff --git a/src/uipath/dev/models/data.py b/src/uipath/dev/models/data.py index 529cb95..83fdc7b 100644 --- a/src/uipath/dev/models/data.py +++ b/src/uipath/dev/models/data.py @@ -50,3 +50,17 @@ 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 1e98f74..1bdfbd7 100644 --- a/src/uipath/dev/server/__init__.py +++ b/src/uipath/dev/server/__init__.py @@ -17,7 +17,13 @@ from uipath.core.tracing import UiPathTraceManager from uipath.runtime import UiPathRuntimeFactoryProtocol -from uipath.dev.models.data import ChatData, LogData, StateData, TraceData +from uipath.dev.models.data import ( + ChatData, + InterruptData, + LogData, + StateData, + TraceData, +) from uipath.dev.models.execution import ExecutionRun from uipath.dev.server.debug_bridge import WebDebugBridge from uipath.dev.services.run_service import RunService @@ -75,6 +81,7 @@ 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), ) @@ -215,6 +222,10 @@ 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/api/websocket.ts b/src/uipath/dev/server/frontend/src/api/websocket.ts index 58d3e53..1621071 100644 --- a/src/uipath/dev/server/frontend/src/api/websocket.ts +++ b/src/uipath/dev/server/frontend/src/api/websocket.ts @@ -96,6 +96,10 @@ 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 }); + } + debugStep(runId: string): void { this.send("debug.step", { run_id: runId }); } diff --git a/src/uipath/dev/server/frontend/src/components/chat/ChatInterrupt.tsx b/src/uipath/dev/server/frontend/src/components/chat/ChatInterrupt.tsx new file mode 100644 index 0000000..d82186a --- /dev/null +++ b/src/uipath/dev/server/frontend/src/components/chat/ChatInterrupt.tsx @@ -0,0 +1,172 @@ +import { useState } from "react"; +import type { InterruptEvent } from "../../types/run"; + +interface Props { + interrupt: InterruptEvent; + onRespond: (data: Record) => void; +} + +export default function ChatInterrupt({ interrupt, onRespond }: Props) { + const [responseText, setResponseText] = useState(""); + + if (interrupt.interrupt_type === "tool_call_confirmation") { + return ( +
+
+ + Action Required + + {interrupt.tool_name && ( + + {interrupt.tool_name} + + )} +
+ {interrupt.input_value != null && ( +
+            {typeof interrupt.input_value === "string"
+              ? interrupt.input_value
+              : JSON.stringify(interrupt.input_value, null, 2)}
+          
+ )} +
+ + +
+
+ ); + } + + // Generic interrupt + return ( +
+
+ + Input Required + +
+ {interrupt.content != null && ( +
+ {typeof interrupt.content === "string" + ? interrupt.content + : JSON.stringify(interrupt.content, null, 2)} +
+ )} +
+ setResponseText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey && responseText.trim()) { + e.preventDefault(); + onRespond({ response: responseText.trim() }); + } + }} + placeholder="Type your response..." + className="flex-1 bg-transparent text-xs py-1 focus:outline-none placeholder:text-[var(--text-muted)]" + style={{ color: "var(--text-primary)" }} + /> + +
+
+ ); +} diff --git a/src/uipath/dev/server/frontend/src/components/chat/ChatPanel.tsx b/src/uipath/dev/server/frontend/src/components/chat/ChatPanel.tsx index bee1884..24e14ee 100644 --- a/src/uipath/dev/server/frontend/src/components/chat/ChatPanel.tsx +++ b/src/uipath/dev/server/frontend/src/components/chat/ChatPanel.tsx @@ -3,6 +3,7 @@ import type { WsClient } from "../../api/websocket"; import { useRunStore } from "../../store/useRunStore"; import ChatMessage from "./ChatMessage"; import ChatInput from "./ChatInput"; +import ChatInterrupt from "./ChatInterrupt"; interface ChatMsg { message_id: string; @@ -23,6 +24,8 @@ export default function ChatPanel({ messages, runId, runStatus, ws }: Props) { const stickToBottom = useRef(true); const addLocalChatMessage = useRunStore((s) => s.addLocalChatMessage); const setFocusedSpan = useRunStore((s) => s.setFocusedSpan); + const interrupt = useRunStore((s) => s.activeInterrupt[runId] ?? null); + const setActiveInterrupt = useRunStore((s) => s.setActiveInterrupt); // Precompute per-tool-call occurrence indices across all messages const toolCallIndicesMap = useMemo(() => { @@ -71,7 +74,13 @@ export default function ChatPanel({ messages, runId, runStatus, ws }: Props) { ws.sendChatMessage(runId, text); }; - const isDisabled = runStatus === "running"; + const handleInterruptResponse = (data: Record) => { + stickToBottom.current = true; + ws.sendInterruptResponse(runId, data); + setActiveInterrupt(runId, null); + }; + + const isDisabled = runStatus === "running" || !!interrupt; return (
@@ -94,6 +103,12 @@ export default function ChatPanel({ messages, runId, runStatus, ws }: Props) { onToolCallClick={(name, idx) => setFocusedSpan({ name, index: idx })} /> ))} + {interrupt && ( + + )}
{showScrollTop && (