From 4cdd92c83550055b8537de830482960c1599ec70 Mon Sep 17 00:00:00 2001 From: chrishayuk Date: Sat, 28 Feb 2026 16:00:50 +0000 Subject: [PATCH 1/7] improving shell --- src/mcp_cli/chat/chat_context.py | 32 +- src/mcp_cli/chat/chat_handler.py | 11 + src/mcp_cli/chat/models.py | 3 +- src/mcp_cli/chat/tool_processor.py | 65 +- src/mcp_cli/chat/ui_manager.py | 37 +- src/mcp_cli/commands/__init__.py | 3 +- src/mcp_cli/commands/sessions/__init__.py | 5 +- src/mcp_cli/commands/sessions/new.py | 107 ++ src/mcp_cli/dashboard/MULTI_AGENT_SPEC.md | 722 +++++++++++++ src/mcp_cli/dashboard/bridge.py | 650 +++++++++++- src/mcp_cli/dashboard/server.py | 14 + src/mcp_cli/dashboard/static/shell.html | 959 ++++++++++++++++-- .../static/views/activity-stream.html | 397 +++++++- .../static/views/agent-terminal.html | 216 +++- .../dashboard/static/views/plan-viewer.html | 295 ++++++ .../dashboard/static/views/tool-browser.html | 430 ++++++++ tests/chat/test_chat_context.py | 9 +- tests/chat/test_memory_integration.py | 2 +- tests/chat/test_tool_processor.py | 6 +- tests/chat/test_tool_processor_extended.py | 6 +- tests/chat/test_ui_manager_coverage.py | 159 ++- tests/dashboard/test_bridge.py | 2 +- tests/dashboard/test_bridge_extended.py | 539 ++++++++++ tests/dashboard/test_integration.py | 4 +- 24 files changed, 4471 insertions(+), 202 deletions(-) create mode 100644 src/mcp_cli/commands/sessions/new.py create mode 100644 src/mcp_cli/dashboard/MULTI_AGENT_SPEC.md create mode 100644 src/mcp_cli/dashboard/static/views/plan-viewer.html create mode 100644 src/mcp_cli/dashboard/static/views/tool-browser.html create mode 100644 tests/dashboard/test_bridge_extended.py diff --git a/src/mcp_cli/chat/chat_context.py b/src/mcp_cli/chat/chat_context.py index 78538b10..5f7738d5 100644 --- a/src/mcp_cli/chat/chat_context.py +++ b/src/mcp_cli/chat/chat_context.py @@ -875,27 +875,35 @@ def load_session(self, session_id: str) -> bool: continue # System prompt is regenerated elif role == MessageRole.USER: event = SessionEvent( - event_type=EventType.USER_MESSAGE, + type=EventType.MESSAGE, source=EventSource.USER, - content=content, + message=content, ) elif role == MessageRole.ASSISTANT: - event = SessionEvent( - event_type=EventType.ASSISTANT_MESSAGE, - source=EventSource.ASSISTANT, - content=content, - ) + # Assistant messages with tool_calls need full dict + if msg_dict.get("tool_calls"): + event = SessionEvent( + type=EventType.TOOL_CALL, + source=EventSource.SYSTEM, + message=msg_dict, + ) + else: + event = SessionEvent( + type=EventType.MESSAGE, + source=EventSource.LLM, + message=content, + ) elif role == MessageRole.TOOL: + # Tool result messages stored as full dict for reconstruction event = SessionEvent( - event_type=EventType.TOOL_RESULT, - source=EventSource.TOOL, - content=content, - metadata={"tool_call_id": msg_dict.get("tool_call_id", "")}, + type=EventType.TOOL_CALL, + source=EventSource.SYSTEM, + message=msg_dict, ) else: continue - self.session.add_event(event) + self.session._session.events.append(event) logger.info( f"Loaded session {session_id} with {len(data.messages)} messages" diff --git a/src/mcp_cli/chat/chat_handler.py b/src/mcp_cli/chat/chat_handler.py index 599435c1..a531b841 100644 --- a/src/mcp_cli/chat/chat_handler.py +++ b/src/mcp_cli/chat/chat_handler.py @@ -135,6 +135,7 @@ def on_progress(msg: str) -> None: ) output.info(f"Dashboard: http://localhost:{_dash_port}") ctx.dashboard_bridge = DashboardBridge(_dash_server) + ctx.dashboard_bridge.set_context(ctx) # Wire REQUEST_TOOL from browser → tool_manager, result back to browser _bridge_ref = ctx.dashboard_bridge @@ -220,6 +221,15 @@ async def _dashboard_execute_tool( return False finally: + # Auto-save session on exit + if ctx is not None and ctx.conversation_history: + try: + path = ctx.save_session() + if path: + logger.info("Session auto-saved: %s", path) + except Exception as exc: + logger.warning("Failed to auto-save session: %s", exc) + # Cleanup if ui: await _safe_cleanup(ui) @@ -227,6 +237,7 @@ async def _dashboard_execute_tool( # Stop dashboard server if running if ctx is not None and ctx.dashboard_bridge is not None: try: + await ctx.dashboard_bridge.on_shutdown() await ctx.dashboard_bridge.server.stop() except Exception as exc: logger.warning("Error stopping dashboard server: %s", exc) diff --git a/src/mcp_cli/chat/models.py b/src/mcp_cli/chat/models.py index 5494ee71..c33aefe1 100644 --- a/src/mcp_cli/chat/models.py +++ b/src/mcp_cli/chat/models.py @@ -350,7 +350,7 @@ def print_tool_call(self, tool_name: str, arguments: dict[str, Any]) -> None: """Print tool call info to console.""" ... - def do_confirm_tool_execution( + async def do_confirm_tool_execution( self, tool_name: str, arguments: dict[str, Any], @@ -358,6 +358,7 @@ def do_confirm_tool_execution( """Ask user to confirm tool execution. Returns True if user confirms, False otherwise. + May route to dashboard if browser clients are connected. """ ... diff --git a/src/mcp_cli/chat/tool_processor.py b/src/mcp_cli/chat/tool_processor.py index 732ed4a9..aa4a94a8 100644 --- a/src/mcp_cli/chat/tool_processor.py +++ b/src/mcp_cli/chat/tool_processor.py @@ -175,7 +175,7 @@ async def process_tool_calls( # Handle user confirmation server_url = self._get_server_url_for_tool(execution_tool_name) if self._should_confirm_tool(execution_tool_name, server_url): - confirmed = self.ui_manager.do_confirm_tool_execution( + confirmed = await self.ui_manager.do_confirm_tool_execution( tool_name=display_name, arguments=raw_arguments ) if not confirmed: @@ -189,30 +189,37 @@ async def process_tool_calls( # Parse arguments arguments = self._parse_arguments(raw_arguments) + # Resolve actual tool name for call_tool proxy + actual_tool = execution_tool_name + if execution_tool_name == DYNAMIC_TOOL_PROXY_NAME: + proxy_name = arguments.get("tool_name") + if proxy_name and isinstance(proxy_name, str): + actual_tool = proxy_name + # ── VM tool interception ──────────────────────────────── # page_fault and search_pages are internal VM operations, # handled by MemoryManager — not routed to MCP ToolManager. - if execution_tool_name in _VM_TOOL_NAMES: + if actual_tool in _VM_TOOL_NAMES: await self._handle_vm_tool( - execution_tool_name, arguments, llm_tool_name, call_id + actual_tool, arguments, llm_tool_name, call_id ) continue # ── Memory scope tool interception ───────────────────── # remember, recall, forget are persistent memory ops, # handled locally — not routed to MCP ToolManager. - if execution_tool_name in _MEMORY_TOOL_NAMES: + if actual_tool in _MEMORY_TOOL_NAMES: await self._handle_memory_tool( - execution_tool_name, arguments, llm_tool_name, call_id + actual_tool, arguments, llm_tool_name, call_id ) continue # ── Plan tool interception ───────────────────────────── # plan_create, plan_execute, plan_create_and_execute are # internal planning ops — not routed to MCP ToolManager. - if execution_tool_name in _PLAN_TOOL_NAMES: + if actual_tool in _PLAN_TOOL_NAMES: await self._handle_plan_tool( - execution_tool_name, arguments, llm_tool_name, call_id + actual_tool, arguments, llm_tool_name, call_id ) continue @@ -581,6 +588,7 @@ async def _on_tool_result(self, result: CTPToolResult) -> None: duration_ms=duration_ms, meta_ui=meta_ui, call_id=result.id, + arguments=actual_arguments, ) except Exception as _bridge_exc: logger.debug("Dashboard bridge on_tool_result error: %s", _bridge_exc) @@ -733,6 +741,20 @@ async def _handle_plan_tool( # Get model_manager for LLM-driven step execution model_manager = getattr(self.context, "model_manager", None) + # Broadcast plan start to dashboard + bridge = getattr(self.context, "dashboard_bridge", None) + plan_title = arguments.get("goal", "Plan") + if bridge is not None: + try: + await bridge.on_plan_update( + plan_id=call_id, + title=plan_title, + steps=[], + status="running", + ) + except Exception as _e: + logger.debug("Dashboard plan start update error: %s", _e) + # Pass the UI manager so handle_plan_tool can show step-by-step progress result_text = await handle_plan_tool( tool_name, @@ -742,6 +764,35 @@ async def _handle_plan_tool( ui_manager=self.ui_manager, ) + # Re-fetch bridge in case it changed during plan execution + bridge = getattr(self.context, "dashboard_bridge", None) + if bridge is not None: + try: + import json as _json + + plan_result = ( + _json.loads(result_text) if isinstance(result_text, str) else {} + ) + steps = plan_result.get("steps", []) + await bridge.on_plan_update( + plan_id=plan_result.get("plan_id", call_id), + title=plan_result.get("title", plan_title), + steps=[ + { + "index": s.get("index", i), + "title": s.get("title", ""), + "tool": s.get("tool", ""), + "status": "complete" if s.get("success") else "failed", + "error": s.get("error"), + } + for i, s in enumerate(steps) + ], + status="complete" if plan_result.get("success") else "failed", + error=plan_result.get("error"), + ) + except Exception as _e: + logger.debug("Dashboard plan update error: %s", _e) + self._add_tool_result_to_history(llm_tool_name, call_id, result_text) async def _handle_vm_tool( diff --git a/src/mcp_cli/chat/ui_manager.py b/src/mcp_cli/chat/ui_manager.py index cb92e749..061ad0f9 100644 --- a/src/mcp_cli/chat/ui_manager.py +++ b/src/mcp_cli/chat/ui_manager.py @@ -230,9 +230,12 @@ def print_tool_call(self, tool_name: str, raw_arguments: Any) -> None: # The display manager shows tool name + arguments during execution pass - def do_confirm_tool_execution(self, tool_name: str, arguments: Any) -> bool: + async def do_confirm_tool_execution(self, tool_name: str, arguments: Any) -> bool: """Prompt user to confirm tool execution. + Routes to dashboard if browser clients are connected, + otherwise falls back to terminal prompt. + Args: tool_name: Name of the tool arguments: Tool arguments @@ -240,8 +243,6 @@ def do_confirm_tool_execution(self, tool_name: str, arguments: Any) -> bool: Returns: True if user confirms, False otherwise """ - from chuk_term.ui import output - # Parse arguments for display if isinstance(arguments, str): try: @@ -251,13 +252,35 @@ def do_confirm_tool_execution(self, tool_name: str, arguments: Any) -> bool: else: args = arguments or {} - # Show tool and arguments - output.warning(f"⚠️ Tool confirmation required: {tool_name}") + # Route to dashboard if clients are connected + bridge = getattr(self.context, "dashboard_bridge", None) + if bridge is not None and bridge.server.has_clients: + try: + call_id = f"confirm-{id(arguments)}-{time.time_ns()}" + fut = await bridge.request_tool_approval( + tool_name=tool_name, + arguments=args, + call_id=call_id, + ) + # Wait for dashboard user to approve/deny (with timeout) + return await asyncio.wait_for(fut, timeout=300) + except asyncio.TimeoutError: + logger.warning("Tool approval timed out for %s", tool_name) + return False + except Exception as exc: + logger.warning("Dashboard tool approval error: %s", exc) + # Fall through to terminal prompt + + # Terminal fallback + output.warning(f"Tool confirmation required: {tool_name}") args_str = json.dumps(args, indent=2) if isinstance(args, dict) else str(args) output.print(f"Parameters:\n{args_str}") - # Prompt for confirmation - response = input("\nExecute this tool? [Y/n]: ").strip().lower() + # Use asyncio-friendly prompt + loop = asyncio.get_running_loop() + response = await loop.run_in_executor( + None, lambda: input("\nExecute this tool? [Y/n]: ").strip().lower() + ) return response in ("", "y", "yes") async def finish_tool_execution( diff --git a/src/mcp_cli/commands/__init__.py b/src/mcp_cli/commands/__init__.py index 4992a150..f751e674 100644 --- a/src/mcp_cli/commands/__init__.py +++ b/src/mcp_cli/commands/__init__.py @@ -105,7 +105,7 @@ def register_all_commands() -> None: from mcp_cli.commands.conversation import ConversationCommand from mcp_cli.commands.usage import UsageCommand from mcp_cli.commands.export import ExportCommand - from mcp_cli.commands.sessions import SessionsCommand + from mcp_cli.commands.sessions import SessionsCommand, NewSessionCommand from mcp_cli.commands.apps import AppsCommand from mcp_cli.commands.memory import MemoryCommand from mcp_cli.commands.plan import PlanCommand @@ -146,6 +146,7 @@ def register_all_commands() -> None: registry.register(UsageCommand()) registry.register(ExportCommand()) registry.register(SessionsCommand()) + registry.register(NewSessionCommand()) # Register tool execution command for interactive mode registry.register(ExecuteToolCommand()) diff --git a/src/mcp_cli/commands/sessions/__init__.py b/src/mcp_cli/commands/sessions/__init__.py index cdf27d76..a87eca1e 100644 --- a/src/mcp_cli/commands/sessions/__init__.py +++ b/src/mcp_cli/commands/sessions/__init__.py @@ -1,5 +1,6 @@ -"""Sessions command.""" +"""Sessions commands.""" from mcp_cli.commands.sessions.sessions import SessionsCommand +from mcp_cli.commands.sessions.new import NewSessionCommand -__all__ = ["SessionsCommand"] +__all__ = ["SessionsCommand", "NewSessionCommand"] diff --git a/src/mcp_cli/commands/sessions/new.py b/src/mcp_cli/commands/sessions/new.py new file mode 100644 index 00000000..774df6a1 --- /dev/null +++ b/src/mcp_cli/commands/sessions/new.py @@ -0,0 +1,107 @@ +# src/mcp_cli/commands/sessions/new.py +"""New session command — start a fresh conversation.""" + +from __future__ import annotations + +from mcp_cli.commands.base import ( + UnifiedCommand, + CommandMode, + CommandParameter, + CommandResult, +) +from chuk_term.ui import output + + +class NewSessionCommand(UnifiedCommand): + """Start a new chat session.""" + + @property + def name(self) -> str: + return "new" + + @property + def aliases(self) -> list[str]: + return [] + + @property + def description(self) -> str: + return "Start a new chat session (saves current session first)" + + @property + def help_text(self) -> str: + return """ +Start a new chat session. + +Usage: + /new - Save current session and start fresh + /new - Save current session and start fresh with a description +""" + + @property + def modes(self) -> CommandMode: + return CommandMode.CHAT + + @property + def parameters(self) -> list[CommandParameter]: + return [ + CommandParameter( + name="description", + type=str, + required=False, + help="Optional description for the new session", + ), + ] + + async def execute(self, **kwargs) -> CommandResult: + """Execute the new session command.""" + chat_context = kwargs.get("chat_context") + if not chat_context: + return CommandResult(success=False, error="No chat context available.") + + args = kwargs.get("args", "").strip() + description = args if args else "" + + # Save current session first (if there's history) + if hasattr(chat_context, "save_session") and chat_context.conversation_history: + try: + path = chat_context.save_session() + if path: + output.info(f"Previous session saved: {path}") + except Exception as e: + output.warning(f"Could not save previous session: {e}") + + # Clear history and start fresh + await chat_context.clear_conversation_history(keep_system_prompt=True) + + # Broadcast to dashboard if connected + bridge = getattr(chat_context, "dashboard_bridge", None) + if bridge is not None: + try: + from mcp_cli.dashboard.bridge import _envelope + + await bridge.server.broadcast( + _envelope("CONVERSATION_HISTORY", {"messages": []}) + ) + config = bridge._build_config_state() + if config: + await bridge.server.broadcast(_envelope("CONFIG_STATE", config)) + await bridge.server.broadcast( + _envelope( + "SESSION_STATE", + { + "session_id": chat_context.session_id, + "description": description, + }, + ) + ) + except Exception: + pass + + output.success( + f"New session started: {chat_context.session_id}" + + (f" ({description})" if description else "") + ) + return CommandResult( + success=True, + output=f"New session: {chat_context.session_id}", + ) diff --git a/src/mcp_cli/dashboard/MULTI_AGENT_SPEC.md b/src/mcp_cli/dashboard/MULTI_AGENT_SPEC.md new file mode 100644 index 00000000..7955d8bb --- /dev/null +++ b/src/mcp_cli/dashboard/MULTI_AGENT_SPEC.md @@ -0,0 +1,722 @@ +# Multi-Agent, Multi-Session Dashboard Spec + +**Status**: Design draft +**Author**: auto-generated from architecture review +**Date**: 2026-02-28 + +--- + +## 1. Goals + +Support a dashboard that can: + +1. Display **multiple agents running in parallel**, each with its own conversation, tool calls, and state. +2. Allow the user to **switch between sessions** for any agent — viewing historical conversations alongside the live one. +3. Present a **unified activity stream** that aggregates tool calls, state changes, and plans across all agents. +4. Route **browser input** (messages, commands, tool approvals) to the correct agent. +5. Remain **backward-compatible** — a single-agent setup works exactly as today with zero configuration. + +--- + +## 2. Concepts + +### 2.1 Agent + +An **agent** is an autonomous LLM-driven entity with: + +- An **agent_id** (e.g. `"agent-main"`, `"agent-research"`, `"agent-coder"`) +- Its own **ChatContext** (model, provider, system prompt, tools, conversation history) +- Its own **ConversationProcessor** and **ToolProcessor** +- Its own **input queue** for receiving browser / terminal input +- A **role** or **description** (free text, displayed in the UI) +- An optional **parent agent_id** (for supervisor → delegate relationships) + +An agent may be: + +| Lifecycle | Description | +|-----------------|-------------| +| **active** | Running its chat loop, can receive input | +| **paused** | Exists but chat loop is suspended (awaiting user or another agent) | +| **completed** | Finished its task, read-only history | +| **failed** | Errored out, read-only history | + +### 2.2 Session + +A **session** is a persisted conversation for an agent: + +- Each agent has a **current session** (live) and zero or more **saved sessions** (on disk). +- Session files are namespaced: `~/.mcp-cli/sessions/{agent_id}/{session_id}.json` +- `SessionMetadata` gains an `agent_id` field. +- The session list endpoint can filter by agent. + +### 2.3 Agent Group + +An **agent group** is the top-level orchestration unit: + +- Contains one or more agents. +- Has a **supervisor agent** (optional) that can spawn/stop other agents. +- Has a shared **plan** (optional) that coordinates agent work. +- Single-agent mode is just a group of one with no supervisor. + +--- + +## 3. Data Model Changes + +### 3.1 SessionMetadata (session_store.py) + +```python +class SessionMetadata(BaseModel): + session_id: str + agent_id: str = "default" # NEW + agent_name: str = "" # NEW — human-readable label + created_at: str + updated_at: str + provider: str + model: str + message_count: int = 0 + description: str = "" + parent_session_id: str | None = None # NEW — if spawned from another session + tags: list[str] = [] # NEW — user-defined tags for organisation +``` + +### 3.2 ChatContext additions + +```python +class ChatContext: + agent_id: str = "default" # NEW — propagated to bridge, sessions + agent_name: str = "" # NEW — human label + agent_role: str = "" # NEW — e.g. "researcher", "coder" + parent_agent_id: str | None # NEW — supervisor link +``` + +### 3.3 AgentDescriptor (new) + +```python +@dataclass +class AgentDescriptor: + """Lightweight descriptor for an agent visible to the dashboard.""" + agent_id: str + name: str + role: str + status: Literal["active", "paused", "completed", "failed"] + model: str + provider: str + session_id: str + parent_agent_id: str | None = None + tool_count: int = 0 + message_count: int = 0 + created_at: str = "" +``` + +--- + +## 4. Bridge Architecture + +### 4.1 Current: 1 Bridge → 1 Server → 1 ChatContext + +``` +Terminal ─── input_queue ──► ChatContext ◄── DashboardBridge ──► DashboardServer ──► Browser +``` + +### 4.2 Future: AgentRouter → N Bridges → 1 Server + +``` + ┌── DashboardBridge(agent-A) ◄──► ChatContext(A) +Terminal ──► AgentRouter ─────────┤ + │ ├── DashboardBridge(agent-B) ◄──► ChatContext(B) + │ │ + │ └── DashboardBridge(agent-C) ◄──► ChatContext(C) + │ + └──► DashboardServer ──► Browser(s) +``` + +**Key principle**: One `DashboardServer` (one WebSocket endpoint, one HTTP port), but messages are **tagged with `agent_id`** and **routed** by an `AgentRouter`. + +### 4.3 AgentRouter (new module: `dashboard/router.py`) + +```python +class AgentRouter: + """Routes messages between browser clients and multiple agent bridges.""" + + def __init__(self, server: DashboardServer) -> None: + self.server = server + self._bridges: dict[str, DashboardBridge] = {} + self._agent_descriptors: dict[str, AgentDescriptor] = {} + + # Which agent each browser client is "focused" on + self._client_focus: dict[ServerConnection, str] = {} # ws → agent_id + + # Wire server callbacks + server.on_browser_message = self._on_browser_message + server.on_client_connected = self._on_client_connected + server.on_client_disconnected = self._on_client_disconnected + + # ── Agent lifecycle ────────────────────────────────────────── + def register_agent(self, agent_id: str, bridge: DashboardBridge, descriptor: AgentDescriptor) -> None: ... + def unregister_agent(self, agent_id: str) -> None: ... + def update_agent_status(self, agent_id: str, status: str) -> None: ... + + # ── Outbound (agent → browser) ────────────────────────────── + async def broadcast_from_agent(self, agent_id: str, message: dict) -> None: + """Inject agent_id into the envelope and broadcast.""" + message["payload"]["agent_id"] = agent_id + await self.server.broadcast(message) + + async def broadcast_global(self, message: dict) -> None: + """Broadcast a message not scoped to any agent (e.g. AGENT_LIST).""" + await self.server.broadcast(message) + + # ── Inbound (browser → agent) ─────────────────────────────── + async def _on_browser_message(self, msg: dict, ws: ServerConnection) -> None: + """Route browser message to the correct agent bridge.""" + agent_id = msg.get("agent_id") or self._client_focus.get(ws) or "default" + bridge = self._bridges.get(agent_id) + if bridge: + await bridge._on_browser_message(msg) + + # ── Focus management ───────────────────────────────────────── + async def _handle_focus_agent(self, msg: dict, ws: ServerConnection) -> None: + """Browser client changed which agent it's viewing.""" + agent_id = msg.get("agent_id", "default") + self._client_focus[ws] = agent_id + # Send full state replay for the focused agent + bridge = self._bridges.get(agent_id) + if bridge: + await bridge._on_client_connected(ws) +``` + +### 4.4 DashboardBridge changes + +The existing `DashboardBridge` stays mostly the same but: + +1. **Receives its `agent_id`** in the constructor. +2. **Does NOT own the server** — the `AgentRouter` owns it. +3. Uses `router.broadcast_from_agent(self.agent_id, msg)` instead of `self.server.broadcast(msg)`. +4. `_on_client_connected` becomes a method that the router calls when a client focuses this agent. +5. Inbound messages already arrive pre-routed by the router. + +```python +class DashboardBridge: + def __init__(self, agent_id: str, router: AgentRouter) -> None: + self.agent_id = agent_id + self.router = router + # ... rest same as today + + async def _broadcast(self, envelope: dict) -> None: + """Broadcast via router (injects agent_id).""" + await self.router.broadcast_from_agent(self.agent_id, envelope) +``` + +**Backward compat**: When `AgentRouter` has only one bridge and it's `"default"`, behavior is identical to today. + +### 4.5 DashboardServer changes + +The server needs to pass the `ws` (client connection) to the message callback so the router can identify which client sent the message: + +```python +# Current: +on_browser_message: Callable[[dict], Awaitable] | None + +# New: +on_browser_message: Callable[[dict, ServerConnection], Awaitable] | None +``` + +The server also gains: + +```python +async def send_to_client(self, ws: ServerConnection, message: str) -> None: + """Send to a specific client (not broadcast).""" + +@property +def clients(self) -> set[ServerConnection]: + """Expose client set for router focus tracking.""" +``` + +--- + +## 5. Message Protocol Changes + +### 5.1 Universal `agent_id` on all payloads + +Every outbound envelope gains `agent_id` in the payload: + +```json +{ + "protocol": "mcp-dashboard", + "version": 2, + "type": "TOOL_RESULT", + "payload": { + "agent_id": "agent-research", + "tool_name": "web_search", + ... + } +} +``` + +**Version bump**: Protocol version goes from 1 → 2. Views check version and handle both. + +### 5.2 New message types + +| Type | Direction | Payload | Description | +|------|-----------|---------|-------------| +| `AGENT_LIST` | server → browser | `{ agents: AgentDescriptor[] }` | Full list of registered agents | +| `AGENT_REGISTERED` | server → browser | `AgentDescriptor` | A new agent joined | +| `AGENT_UNREGISTERED` | server → browser | `{ agent_id }` | An agent left | +| `AGENT_STATUS` | server → browser | `{ agent_id, status, ... }` | Agent status update (active/paused/completed/failed) | +| `FOCUS_AGENT` | browser → server | `{ agent_id }` | Client wants to focus a different agent | +| `REQUEST_AGENT_LIST` | browser → server | `{}` | Client requests current agent list | + +### 5.3 Inbound messages gain `agent_id` + +All browser → server messages can optionally include `agent_id`: + +```json +{ "type": "USER_MESSAGE", "agent_id": "agent-coder", "content": "Fix the bug" } +``` + +If omitted, the router uses the client's current focus agent. + +### 5.4 Session messages gain `agent_id` scoping + +| Type | Change | +|------|--------| +| `REQUEST_SESSIONS` | Response filtered by `agent_id` of focused agent | +| `SESSION_LIST` | Includes `agent_id` per session entry | +| `SWITCH_SESSION` | Can specify `agent_id` to switch a different agent's session | + +--- + +## 6. Shell (UI) Changes + +### 6.1 Agent Selector + +The shell header gains an **agent tab bar**: + +``` +┌─────────────────────────────────────────────────────┐ +│ [Main ●] [Research ◐] [Coder ○] ⚙ Settings │ +├─────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────┐ ┌─────────────────────────┐ │ +│ │ Agent Terminal │ │ Activity Stream │ │ +│ │ (focused agent) │ │ (all agents or filtered) │ │ +│ └─────────────────┘ └─────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────┘ +``` + +- Each tab shows: agent name, status indicator (●=active, ◐=paused, ○=completed, ✗=failed) +- Clicking a tab sends `FOCUS_AGENT` → router replays that agent's state +- The currently focused agent receives keyboard/browser input + +### 6.2 Agent-scoped views + +When the focus changes: + +1. Shell sends `FOCUS_AGENT` to the server. +2. Router responds with `CONVERSATION_HISTORY`, `ACTIVITY_HISTORY`, `CONFIG_STATE`, `TOOL_REGISTRY` for the focused agent. +3. Views that are agent-scoped (agent-terminal, plan-viewer) update. +4. Views that are global (activity-stream in "all agents" mode) continue showing everything. + +### 6.3 Activity stream modes + +The activity stream gains a filter dropdown: + +| Mode | Behavior | +|------|----------| +| **All Agents** | Shows events from every agent, color-coded by agent | +| **Focused Agent** | Only shows events from the currently focused agent | +| **Agent X** | Pin to a specific agent regardless of focus | + +Each event card gains an agent badge: + +``` +┌─ agent-research ──────────────────────────┐ +│ 🔧 web_search 342ms │ +│ ✓ 3 results 14:23:01 │ +└───────────────────────────────────────────┘ +``` + +### 6.4 Message routing changes in shell.html + +```javascript +// Current: direct routing +case 'TOOL_RESULT': + routeToViews('TOOL_RESULT', msg.payload); + break; + +// Future: agent-aware routing +case 'TOOL_RESULT': + const agentId = msg.payload.agent_id; + // Always send to activity stream (it filters internally) + sendToActivityStream('TOOL_RESULT', msg.payload); + // Only send to agent-terminal if this is the focused agent + if (agentId === focusedAgentId) { + routeToFocusedViews('TOOL_RESULT', msg.payload); + } + break; +``` + +--- + +## 7. View Changes + +### 7.1 Agent Terminal + +- Displays conversation for **one agent at a time** (the focused agent). +- On `FOCUS_AGENT` change, clears and replays from `CONVERSATION_HISTORY`. +- Input always routes to the focused agent. +- Status bar shows: `agent-name · model · tokens · turn` + +### 7.2 Activity Stream + +- Each event card has an **agent badge** (colored dot + short name). +- Agent colors are assigned from a palette based on registration order. +- Filter bar gains agent dropdown alongside existing server/status filters. +- `ACTIVITY_HISTORY` replays are tagged with the originating agent_id. + +### 7.3 Plan Viewer + +- Shows plans for the **focused agent** by default. +- Can optionally show a "supervisor plan" that spans multiple agents. +- Each plan step can indicate which agent is executing it. + +### 7.4 Config Panel + +- Shows config for the **focused agent** (model, provider, servers, system prompt). +- Model/provider switching applies to the focused agent only. +- Shows a read-only summary of other agents' configs. + +### 7.5 NEW: Agent Overview Panel (`builtin:agent-overview`) + +A new built-in view showing all agents at a glance: + +``` +┌─────────────────────────────────────────────────┐ +│ Agent Overview │ +├─────────────────────────────────────────────────┤ +│ │ +│ ┌─ agent-main ──────────────────────┐ │ +│ │ ● Active │ claude-sonnet │ 42 msgs │ +│ │ Role: Supervisor │ │ +│ │ Current: Planning next steps... │ │ +│ └────────────────────────────────────┘ │ +│ │ +│ ┌─ agent-research ──────────────────┐ │ +│ │ ◐ Tool calling │ gpt-4o │ 18 msgs │ +│ │ Role: Research assistant │ │ +│ │ Current: web_search("mcp spec") │ │ +│ └────────────────────────────────────┘ │ +│ │ +│ ┌─ agent-coder ─────────────────────┐ │ +│ │ ○ Completed │ claude-opus │ 67 msgs │ +│ │ Role: Code implementation │ │ +│ │ Finished: All tests passing │ │ +│ └────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────┘ +``` + +--- + +## 8. Session Management for Multi-Agent + +### 8.1 Storage layout + +``` +~/.mcp-cli/sessions/ +├── default/ # single-agent (backward compat) +│ ├── chat-a1b2c3d4e5f6.json +│ └── chat-f6e5d4c3b2a1.json +├── agent-research/ +│ ├── chat-111111111111.json +│ └── chat-222222222222.json +└── agent-coder/ + └── chat-333333333333.json +``` + +### 8.2 Session list scoping + +`SessionStore.list_sessions(agent_id=None)`: + +- `agent_id=None` → list all sessions across all agents +- `agent_id="agent-research"` → list only that agent's sessions + +### 8.3 Session switching per agent + +Each agent can independently switch sessions without affecting other agents: + +```json +// Browser sends: +{ "type": "SWITCH_SESSION", "agent_id": "agent-research", "session_id": "chat-222222222222" } + +// Router routes to agent-research's bridge +// Bridge loads the session, broadcasts CONVERSATION_HISTORY + ACTIVITY_HISTORY +// Only clients focused on agent-research see the update +``` + +### 8.4 Group save/restore + +Save/restore an entire agent group: + +``` +~/.mcp-cli/groups/ +└── my-project-2026-02-28/ + ├── group.json # agent descriptors, relationships, shared plan + ├── agent-main/session.json + ├── agent-research/session.json + └── agent-coder/session.json +``` + +`group.json`: +```json +{ + "group_id": "grp-abc123", + "created_at": "2026-02-28T12:00:00Z", + "description": "Project X implementation", + "agents": [ + { + "agent_id": "agent-main", + "name": "Main", + "role": "supervisor", + "model": "claude-sonnet", + "provider": "anthropic", + "session_id": "chat-aaa", + "parent_agent_id": null + }, + { + "agent_id": "agent-research", + "name": "Research", + "role": "researcher", + "model": "gpt-4o", + "provider": "openai", + "session_id": "chat-bbb", + "parent_agent_id": "agent-main" + } + ], + "plan": { ... } +} +``` + +--- + +## 9. Inter-Agent Communication + +### 9.1 Message passing + +Agents can send messages to each other through the router: + +```python +# New message type +{ "type": "AGENT_MESSAGE", "from_agent": "agent-main", "to_agent": "agent-research", "content": "Search for MCP spec v2" } +``` + +The router delivers this to the target agent's input queue as a system-injected message. + +### 9.2 Shared context / artifacts + +Agents can publish artifacts (text, data, files) to a shared store: + +```python +{ "type": "PUBLISH_ARTIFACT", "agent_id": "agent-research", "artifact_id": "search-results", "content": "..." } +{ "type": "REQUEST_ARTIFACT", "agent_id": "agent-coder", "artifact_id": "search-results" } +``` + +The dashboard can visualize these as edges in the agent overview. + +### 9.3 Supervisor delegation + +A supervisor agent can: + +1. **Spawn** a new agent: `{ "type": "SPAWN_AGENT", "descriptor": {...}, "initial_prompt": "..." }` +2. **Stop** an agent: `{ "type": "STOP_AGENT", "agent_id": "agent-research" }` +3. **Query** agent status: `{ "type": "REQUEST_AGENT_STATUS", "agent_id": "agent-research" }` +4. **Receive** completion notifications: `{ "type": "AGENT_COMPLETED", "agent_id": "agent-research", "summary": "..." }` + +These are implemented as internal tools available to the supervisor: + +```python +# Planning/orchestration tools +agent_spawn(name, role, model, provider, initial_prompt) -> agent_id +agent_stop(agent_id) -> bool +agent_status(agent_id) -> AgentDescriptor +agent_message(agent_id, content) -> str +agent_wait(agent_id) -> completion_summary +``` + +--- + +## 10. Tool State Isolation + +### 10.1 Current problem + +`get_tool_state()` from `chuk_ai_session_manager.guards` returns a **module-level singleton**. Multiple agents sharing the same process would share tool limits, rate counters, and guard state. + +### 10.2 Solution + +```python +# Per-agent tool state factory +def get_tool_state(agent_id: str = "default") -> ToolState: + """Return a ToolState scoped to the given agent.""" + ... + +# In ChatContext.__init__: +self._tool_state = get_tool_state(self.agent_id) + +# In ConversationProcessor.__init__: +self._tool_state = self.context._tool_state # no more module-level call +``` + +### 10.3 Per-agent tool filtering + +Each agent can have a restricted tool set: + +```python +class AgentConfig: + agent_id: str + allowed_tools: list[str] | None = None # None = all tools + denied_tools: list[str] | None = None # explicit blocklist + allowed_servers: list[str] | None = None # None = all servers + tool_timeout_override: float | None = None + auto_approve_tools: list[str] | None = None # skip confirmation for these +``` + +--- + +## 11. WebSocket Protocol v2 + +### 11.1 Envelope changes + +```json +{ + "protocol": "mcp-dashboard", + "version": 2, + "type": "TOOL_RESULT", + "agent_id": "agent-research", + "payload": { ... } +} +``` + +`agent_id` moves to the **envelope level** (not buried in payload) for efficient routing without deserializing payload. + +### 11.2 Client subscription model + +Instead of "broadcast everything to everyone", clients subscribe: + +```json +// Client sends on connect: +{ "type": "SUBSCRIBE", "agents": ["agent-main", "agent-research"], "global": true } + +// Server only sends events for subscribed agents + global events +// Client can update subscription: +{ "type": "SUBSCRIBE", "agents": ["agent-coder"], "global": true } +``` + +Benefits: +- Reduces bandwidth for multi-agent setups +- Allows activity stream to subscribe to all, terminal to subscribe to one +- Popout windows can subscribe independently + +### 11.3 Per-view subscriptions + +Views declare what they want in their READY message: + +```json +{ + "type": "READY", + "payload": { + "name": "Agent Terminal", + "accepts": ["CONVERSATION_MESSAGE", "CONVERSATION_TOKEN", ...], + "agent_scope": "focused", // "focused" | "all" | specific agent_id + "version": 2 + } +} +``` + +The shell uses this to route efficiently. + +--- + +## 12. Implementation Phases + +### Phase A: Foundation (agent_id plumbing) + +**No visible multi-agent yet — just add the wiring.** + +1. Add `agent_id` field to `ChatContext`, `SessionMetadata`, all bridge payloads +2. Add `agent_id` to envelope level (protocol v2, backward compat with v1) +3. Namespace session storage: `sessions/{agent_id}/` +4. Pass `agent_id` through `ToolProcessor`, `ConversationProcessor` +5. Shell ignores `agent_id` for now (treats everything as "default") + +**Tests**: Verify all payloads carry `agent_id`, session files go in right directory, backward compat with existing `sessions/` files (auto-migrate to `sessions/default/`). + +### Phase B: AgentRouter + Server changes + +1. Implement `AgentRouter` class +2. Modify `DashboardServer` to pass `ws` to message callbacks +3. Add `send_to_client()` method on server +4. Bridge uses router instead of server directly +5. Single-agent mode: router has one bridge, behaves identically to today + +**Tests**: Router with 1 bridge = same as direct bridge. Router with 2 bridges routes correctly. Focus switching replays correct state. + +### Phase C: Shell multi-agent UI + +1. Agent tab bar in shell header +2. `FOCUS_AGENT` message type +3. Agent-scoped view routing +4. Activity stream agent filtering + badges +5. Agent overview panel (new built-in view) +6. `AGENT_LIST` / `AGENT_REGISTERED` / `AGENT_UNREGISTERED` handling + +**Tests**: UI tests with mock agents. Focus switching. Activity stream filtering. + +### Phase D: Agent orchestration + +1. `AgentConfig` model for per-agent tool/server restrictions +2. Supervisor tools: `agent_spawn`, `agent_stop`, `agent_message`, `agent_wait` +3. Inter-agent message passing via router +4. Shared artifact store +5. Group save/restore + +**Tests**: Spawn/stop lifecycle. Message delivery. Artifact publish/request. Group save/load. + +### Phase E: Client subscriptions + scale + +1. Subscription model on WebSocket +2. Per-view agent_scope in READY +3. Efficient routing (only send to subscribed clients) +4. Tool state isolation per agent +5. Performance testing with 5+ concurrent agents + +--- + +## 13. Backward Compatibility + +| Concern | Strategy | +|---------|----------| +| Existing sessions in `~/.mcp-cli/sessions/` | Auto-migrate to `sessions/default/` on first access | +| Protocol v1 clients | Router checks version, omits `agent_id` from envelope for v1 | +| Single-agent mode | `AgentRouter` with one `"default"` bridge = transparent | +| Bridge API | Keep `DashboardBridge` constructor accepting `server` (creates implicit router) | +| No `--multi-agent` flag | Default behavior is single agent, no overhead | +| `agent_id: "default"` in payloads | All existing views ignore unknown fields gracefully | + +--- + +## 14. Open Questions + +1. **Agent model heterogeneity**: Can agents use different providers simultaneously? (Yes, each has its own ChatContext with its own provider/model — already supported.) + +2. **Shared tool state**: Should agents share rate limits or have fully independent budgets? (Recommend: independent by default, shared optionally via AgentConfig.) + +3. **Agent-to-agent tool access**: Can agent A call a tool that only agent B has? (Recommend: no, each agent has its own ToolManager. Delegation should go through message passing.) + +4. **Browser multi-tab**: Multiple browser tabs each focused on different agents — does this create separate WebSocket connections? (Yes, each tab connects independently, each has its own focus.) + +5. **Terminal input in multi-agent**: The terminal (stdin) can only feed one agent at a time. How? (Focus concept applies to terminal too — terminal input goes to the "active" agent. `/focus agent-research` switches terminal focus.) + +6. **Maximum agent count**: What's the practical limit? (Probably 5-10 concurrent agents due to LLM API concurrency, memory, and cognitive overload for the user.) + +7. **Cost tracking per agent**: Should token usage be tracked per agent? (Yes — TokenTracker is already per ChatContext, just needs surfacing in the dashboard.) diff --git a/src/mcp_cli/dashboard/bridge.py b/src/mcp_cli/dashboard/bridge.py index 92afc0d5..44cce2d7 100644 --- a/src/mcp_cli/dashboard/bridge.py +++ b/src/mcp_cli/dashboard/bridge.py @@ -9,19 +9,23 @@ Hook call sites: tool_processor._on_tool_result() → bridge.on_tool_result() conversation.process_user_input() → bridge.on_agent_state(), bridge.on_message() - streaming_handler._process_chunk() → bridge.on_token() (Phase 4) + streaming_handler._process_chunk() → bridge.on_token() """ from __future__ import annotations import asyncio import datetime +import json as _json import logging from collections.abc import Awaitable, Callable -from typing import Any, Literal +from typing import TYPE_CHECKING, Any, Literal from mcp_cli.dashboard.server import DashboardServer +if TYPE_CHECKING: + from mcp_cli.chat.chat_context import ChatContext + logger = logging.getLogger(__name__) _PROTOCOL = "mcp-dashboard" @@ -47,6 +51,7 @@ class DashboardBridge: def __init__(self, server: DashboardServer) -> None: self.server = server self._turn_number: int = 0 + self._ctx: ChatContext | None = None # Set this to inject user messages from the browser back into the chat engine self._user_message_callback: Callable[[str], None] | None = None # Queue for injecting browser messages into the chat loop @@ -58,9 +63,35 @@ def __init__(self, server: DashboardServer) -> None: # View registry discovered from _meta.ui fields in tool results self._view_registry: list[dict[str, Any]] = [] self._seen_view_ids: set[str] = set() + # Pending tool approval futures keyed by call_id + self._pending_approvals: dict[str, asyncio.Future[bool]] = {} # Wire server callbacks server.on_browser_message = self._on_browser_message server.on_client_connected = self._on_client_connected + server.on_client_disconnected = self.on_client_disconnected + + def set_context(self, ctx: ChatContext) -> None: + """Store a back-reference to ChatContext for history/config queries.""" + self._ctx = ctx + + async def on_shutdown(self) -> None: + """Cancel all pending approval futures. Call before stopping server.""" + for call_id, fut in list(self._pending_approvals.items()): + if not fut.done(): + fut.set_result(False) + self._pending_approvals.clear() + + async def on_client_disconnected(self) -> None: + """Called when a browser client disconnects. + + If no clients remain, cancel all pending approval futures so the + tool processor doesn't hang waiting for a response that will never come. + """ + if not self.server.has_clients: + for call_id, fut in list(self._pending_approvals.items()): + if not fut.done(): + fut.set_result(False) + self._pending_approvals.clear() # ------------------------------------------------------------------ # # Outbound hooks (chat engine → browser) # @@ -76,6 +107,7 @@ async def on_tool_result( duration_ms: int | None = None, meta_ui: Any = None, call_id: str | None = None, + arguments: dict[str, Any] | None = None, ) -> None: """Called after every tool execution completes.""" payload: dict[str, Any] = { @@ -88,6 +120,7 @@ async def on_tool_result( "result": self._serialise(result), "error": error, "success": success, + "arguments": self._serialise(arguments) if arguments else None, } if meta_ui is not None: payload["meta_ui"] = self._serialise(meta_ui) @@ -141,7 +174,7 @@ async def on_token(self, token: str, done: bool = False) -> None: async def on_view_registry_update(self, views: list[dict[str, Any]]) -> None: """Called when the set of available views changes (server connect/disconnect).""" - await self.server.broadcast({"type": "VIEW_REGISTRY", "views": views}) + await self.server.broadcast(_envelope("VIEW_REGISTRY", {"views": views})) async def _discover_view(self, meta_ui: dict[str, Any], server_name: str) -> None: """Register a new view from a _meta.ui block and broadcast VIEW_REGISTRY.""" @@ -164,17 +197,39 @@ async def _discover_view(self, meta_ui: dict[str, Any], server_name: str) -> Non await self.on_view_registry_update(self._view_registry) async def _on_client_connected(self, ws: Any) -> None: - """Send current VIEW_REGISTRY to a newly connected browser client.""" - if not self._view_registry: - return - import json as _json - + """Send current state to a newly connected browser client.""" try: - await ws.send( - _json.dumps({"type": "VIEW_REGISTRY", "views": self._view_registry}) - ) + # VIEW_REGISTRY + if self._view_registry: + await ws.send( + _json.dumps( + _envelope("VIEW_REGISTRY", {"views": self._view_registry}) + ) + ) + # CONFIG_STATE (model, provider, servers, system prompt preview) + config = self._build_config_state() + if config: + await ws.send(_json.dumps(_envelope("CONFIG_STATE", config))) + # TOOL_REGISTRY + tools = await self._build_tool_registry() + if tools is not None: + await ws.send(_json.dumps(_envelope("TOOL_REGISTRY", {"tools": tools}))) + # CONVERSATION_HISTORY replay (chat view) + history = self._build_conversation_history() + if history: + await ws.send( + _json.dumps( + _envelope("CONVERSATION_HISTORY", {"messages": history}) + ) + ) + # ACTIVITY_HISTORY replay (activity stream) + activity = self._build_activity_history() + if activity: + await ws.send( + _json.dumps(_envelope("ACTIVITY_HISTORY", {"events": activity})) + ) except Exception as exc: - logger.debug("Error sending VIEW_REGISTRY to new client: %s", exc) + logger.debug("Error sending initial state to new client: %s", exc) # ------------------------------------------------------------------ # # Inbound messages (browser → chat engine) # @@ -239,7 +294,6 @@ async def _on_browser_message(self, msg: dict[str, Any]) -> None: elif msg_type == "USER_ACTION": action = msg.get("action") or "" content = msg.get("content") or "" - # Prefer explicit content; fall back to slash-command form of action name text = content or (f"/{action}" if action else "") if text and self._input_queue is not None: try: @@ -248,31 +302,583 @@ async def _on_browser_message(self, msg: dict[str, Any]) -> None: logger.warning("Error queuing USER_ACTION: %s", exc) else: logger.debug("Dashboard USER_ACTION not routed: %s", msg) + elif msg_type == "REQUEST_CONFIG": + await self._handle_request_config() + elif msg_type == "REQUEST_TOOLS": + await self._handle_request_tools() + elif msg_type == "SWITCH_MODEL": + await self._handle_switch_model(msg) + elif msg_type == "UPDATE_SYSTEM_PROMPT": + await self._handle_update_system_prompt(msg) + elif msg_type == "TOOL_APPROVAL_RESPONSE": + await self._handle_tool_approval_response(msg) + elif msg_type == "CLEAR_HISTORY": + await self._handle_clear_history() + elif msg_type == "NEW_SESSION": + await self._handle_new_session(msg) + elif msg_type == "REQUEST_SESSIONS": + await self._handle_request_sessions() + elif msg_type == "SWITCH_SESSION": + await self._handle_switch_session(msg) + elif msg_type == "DELETE_SESSION": + await self._handle_delete_session(msg) + elif msg_type == "RENAME_SESSION": + await self._handle_rename_session(msg) else: logger.debug("Dashboard received unknown message type: %s", msg_type) + # ------------------------------------------------------------------ # + # Config / Model / System-prompt handlers # + # ------------------------------------------------------------------ # + + async def _handle_request_config(self) -> None: + """Browser requested current config — broadcast CONFIG_STATE.""" + config = self._build_config_state() + if config: + await self.server.broadcast(_envelope("CONFIG_STATE", config)) + + async def _handle_switch_model(self, msg: dict[str, Any]) -> None: + """Browser requested a model switch.""" + ctx = self._ctx + if not ctx: + logger.debug("SWITCH_MODEL: no context set") + return + provider = msg.get("provider") or "" + model = msg.get("model") or "" + if not provider or not model: + logger.debug("SWITCH_MODEL: missing provider or model") + return + try: + ctx.model_manager.switch_model(provider, model) + await ctx.refresh_after_model_change() + logger.info("Dashboard: switched to %s/%s", provider, model) + except Exception as exc: + logger.warning("Dashboard SWITCH_MODEL failed: %s", exc) + # Broadcast updated state + config = self._build_config_state() + if config: + await self.server.broadcast(_envelope("CONFIG_STATE", config)) + + async def _handle_update_system_prompt(self, msg: dict[str, Any]) -> None: + """Browser updated the system prompt.""" + ctx = self._ctx + if not ctx: + logger.debug("UPDATE_SYSTEM_PROMPT: no context set") + return + new_prompt = msg.get("system_prompt") + if new_prompt is None: + return + try: + if new_prompt == "": + # Empty string → regenerate default prompt + await ctx.regenerate_system_prompt() + else: + ctx._system_prompt = new_prompt + await ctx.session.update_system_prompt(new_prompt) + logger.info( + "Dashboard: system prompt updated (%d chars)", len(ctx._system_prompt) + ) + except Exception as exc: + logger.warning("Dashboard UPDATE_SYSTEM_PROMPT failed: %s", exc) + config = self._build_config_state() + if config: + await self.server.broadcast(_envelope("CONFIG_STATE", config)) + + # ------------------------------------------------------------------ # + # Clear history handler # + # ------------------------------------------------------------------ # + + async def _handle_clear_history(self) -> None: + """Browser requested conversation history clear.""" + ctx = self._ctx + if not ctx: + logger.debug("CLEAR_HISTORY: no context set") + return + try: + await ctx.clear_conversation_history(keep_system_prompt=True) + logger.info("Dashboard: conversation history cleared") + except Exception as exc: + logger.warning("Dashboard CLEAR_HISTORY failed: %s", exc) + # Broadcast empty history + clear activity stream + await self.server.broadcast(_envelope("CONVERSATION_HISTORY", {"messages": []})) + await self.server.broadcast(_envelope("ACTIVITY_HISTORY", {"events": []})) + config = self._build_config_state() + if config: + await self.server.broadcast(_envelope("CONFIG_STATE", config)) + + # ------------------------------------------------------------------ # + # New session handler # + # ------------------------------------------------------------------ # + + async def _handle_new_session(self, msg: dict[str, Any]) -> None: + """Browser requested a new session (save current + start fresh).""" + ctx = self._ctx + if not ctx: + logger.debug("NEW_SESSION: no context set") + return + description = msg.get("description", "") + + # Save current session first (if there's history) + if hasattr(ctx, "save_session") and ctx.conversation_history: + try: + path = ctx.save_session() + if path: + logger.info("Dashboard: previous session saved: %s", path) + except Exception as exc: + logger.warning("Dashboard: could not save previous session: %s", exc) + + # Clear history and start fresh + try: + await ctx.clear_conversation_history(keep_system_prompt=True) + logger.info("Dashboard: new session started: %s", ctx.session_id) + except Exception as exc: + logger.warning("Dashboard NEW_SESSION failed: %s", exc) + return + + # Broadcast fresh state to all clients + clear activity stream + await self.server.broadcast(_envelope("CONVERSATION_HISTORY", {"messages": []})) + await self.server.broadcast(_envelope("ACTIVITY_HISTORY", {"events": []})) + config = self._build_config_state() + if config: + await self.server.broadcast(_envelope("CONFIG_STATE", config)) + await self.server.broadcast( + _envelope( + "SESSION_STATE", + { + "session_id": ctx.session_id, + "description": description, + }, + ) + ) + + # ------------------------------------------------------------------ # + # Session management handlers # + # ------------------------------------------------------------------ # + + async def _handle_request_sessions(self) -> None: + """Browser requested list of saved sessions.""" + ctx = self._ctx + if not ctx: + return + try: + store = getattr(ctx, "_session_store", None) + if not store: + return + sessions = store.list_sessions() + payload = { + "sessions": [ + { + "session_id": s.session_id, + "created_at": s.created_at, + "updated_at": s.updated_at, + "provider": s.provider, + "model": s.model, + "message_count": s.message_count, + "description": s.description, + "is_current": s.session_id == ctx.session_id, + } + for s in sessions + ], + "current_session_id": ctx.session_id, + } + await self.server.broadcast(_envelope("SESSION_LIST", payload)) + except Exception as exc: + logger.warning("Error building session list: %s", exc) + + async def _handle_switch_session(self, msg: dict[str, Any]) -> None: + """Browser requested to switch to a different session.""" + ctx = self._ctx + if not ctx: + return + session_id = msg.get("session_id", "") + if not session_id: + return + + # Save current session first + if ctx.conversation_history: + try: + ctx.save_session() + except Exception: + pass + + # Clear and load the target session + try: + await ctx.clear_conversation_history(keep_system_prompt=True) + loaded = ctx.load_session(session_id) + if not loaded: + logger.warning("Failed to load session %s", session_id) + return + # Override session_id to match loaded session + ctx.session_id = session_id + logger.info("Dashboard: switched to session %s", session_id) + except Exception as exc: + logger.warning("Dashboard SWITCH_SESSION failed: %s", exc) + return + + # Broadcast updated state + history = self._build_conversation_history() + await self.server.broadcast( + _envelope("CONVERSATION_HISTORY", {"messages": history or []}) + ) + # Activity stream replay for loaded session + activity = self._build_activity_history() + await self.server.broadcast( + _envelope("ACTIVITY_HISTORY", {"events": activity or []}) + ) + config = self._build_config_state() + if config: + await self.server.broadcast(_envelope("CONFIG_STATE", config)) + await self.server.broadcast( + _envelope("SESSION_STATE", {"session_id": session_id}) + ) + # Refresh session list + await self._handle_request_sessions() + + async def _handle_delete_session(self, msg: dict[str, Any]) -> None: + """Browser requested to delete a saved session.""" + ctx = self._ctx + if not ctx: + return + session_id = msg.get("session_id", "") + if not session_id: + return + # Don't allow deleting the current session + if session_id == ctx.session_id: + logger.debug("Cannot delete the current active session") + return + try: + store = getattr(ctx, "_session_store", None) + if store: + store.delete(session_id) + logger.info("Dashboard: deleted session %s", session_id) + except Exception as exc: + logger.warning("Dashboard DELETE_SESSION failed: %s", exc) + # Refresh session list + await self._handle_request_sessions() + + async def _handle_rename_session(self, msg: dict[str, Any]) -> None: + """Browser requested to rename/describe a session.""" + ctx = self._ctx + if not ctx: + return + session_id = msg.get("session_id", "") + description = msg.get("description", "") + if not session_id: + return + try: + store = getattr(ctx, "_session_store", None) + if not store: + return + data = store.load(session_id) + if data: + data.metadata.description = description + store.save(data) + logger.info( + "Dashboard: renamed session %s → %s", session_id, description + ) + except Exception as exc: + logger.warning("Dashboard RENAME_SESSION failed: %s", exc) + # Refresh session list + await self._handle_request_sessions() + + # ------------------------------------------------------------------ # + # Tool registry handler # + # ------------------------------------------------------------------ # + + async def _handle_request_tools(self) -> None: + """Browser requested current tool list — broadcast TOOL_REGISTRY.""" + tools = await self._build_tool_registry() + if tools is not None: + await self.server.broadcast(_envelope("TOOL_REGISTRY", {"tools": tools})) + + async def _build_tool_registry(self) -> list[dict[str, Any]] | None: + """Build tool registry payload from ChatContext's tool_manager.""" + ctx = self._ctx + if not ctx: + return None + try: + tm = getattr(ctx, "tool_manager", None) + if not tm: + return None + all_tools = await tm.get_all_tools() + return [ + { + "name": t.name, + "namespace": t.namespace, + "description": t.description, + "parameters": t.parameters, + "is_async": t.is_async, + "tags": t.tags, + "supports_streaming": t.supports_streaming, + } + for t in all_tools + ] + except Exception as exc: + logger.debug("Error building tool registry: %s", exc) + return None + + # ------------------------------------------------------------------ # + # Tool approval (dashboard ↔ tool_processor) # + # ------------------------------------------------------------------ # + + async def request_tool_approval( + self, + tool_name: str, + arguments: Any, + call_id: str, + ) -> asyncio.Future[bool]: + """Send a tool approval request to the dashboard. + + The browser will show an approve/deny dialog and respond with + TOOL_APPROVAL_RESPONSE. Returns a Future that resolves to True/False. + """ + payload: dict[str, Any] = { + "tool_name": tool_name, + "arguments": self._serialise(arguments), + "call_id": call_id, + "timestamp": _now(), + } + # Create a future that the tool processor can await + fut: asyncio.Future[bool] = asyncio.get_running_loop().create_future() + self._pending_approvals[call_id] = fut + await self.server.broadcast(_envelope("TOOL_APPROVAL_REQUEST", payload)) + return fut + + async def _handle_tool_approval_response(self, msg: dict[str, Any]) -> None: + """Handle approval/denial response from the dashboard.""" + call_id = msg.get("call_id", "") + approved = msg.get("approved", False) + fut = self._pending_approvals.pop(call_id, None) + if fut and not fut.done(): + fut.set_result(approved) + + # ------------------------------------------------------------------ # + # Plan updates (chat engine → browser) # + # ------------------------------------------------------------------ # + + async def on_plan_update( + self, + plan_id: str, + title: str, + steps: list[dict[str, Any]], + status: str = "running", + current_step: int | None = None, + error: str | None = None, + ) -> None: + """Broadcast a plan update to the dashboard.""" + payload: dict[str, Any] = { + "plan_id": plan_id, + "title": title, + "steps": steps, + "status": status, + "current_step": current_step, + "error": error, + "timestamp": _now(), + } + await self.server.broadcast(_envelope("PLAN_UPDATE", payload)) + + # ------------------------------------------------------------------ # + # State builders # + # ------------------------------------------------------------------ # + + def _build_config_state(self) -> dict[str, Any] | None: + """Build CONFIG_STATE payload from current ChatContext.""" + ctx = self._ctx + if not ctx: + return None + try: + provider = ctx.provider + model = ctx.model + mm = ctx.model_manager + + # Build provider → models map + available_providers: list[dict[str, Any]] = [] + for pname in mm.get_available_providers(): + try: + models = mm.get_available_models(pname) + except Exception: + models = [] + available_providers.append({"name": pname, "models": models}) + + # Server info + servers: list[dict[str, Any]] = [] + for si in getattr(ctx, "server_info", []) or []: + servers.append( + { + "id": si.id, + "name": si.name, + "namespace": si.namespace, + "status": si.status, + "tool_count": si.tool_count, + "connected": si.connected, + "transport": str(si.transport), + } + ) + + # System prompt preview (first 500 chars) + sys_prompt = getattr(ctx, "_system_prompt", "") or "" + + return { + "provider": provider, + "model": model, + "available_providers": available_providers, + "servers": servers, + "system_prompt": sys_prompt, + } + except Exception as exc: + logger.debug("Error building CONFIG_STATE: %s", exc) + return None + + def _build_conversation_history(self) -> list[dict[str, Any]] | None: + """Build conversation history payload from ChatContext. + + Returns only user/assistant messages — tool-role messages are excluded + from the chat view and instead served via ``_build_activity_history()``. + """ + ctx = self._ctx + if not ctx: + return None + try: + messages: list[dict[str, Any]] = [] + for msg in ctx.conversation_history: + d = msg.to_dict() + role = d.get("role", "") + # Skip system and tool messages — system isn't shown, + # tool results belong in the activity stream, not chat + if role in ("system", "tool"): + continue + messages.append( + { + "role": role, + "content": d.get("content") or "", + "tool_calls": d.get("tool_calls"), + "reasoning": d.get("reasoning_content"), + } + ) + return messages if messages else None + except Exception as exc: + logger.debug("Error building conversation history: %s", exc) + return None + + def _build_activity_history(self) -> list[dict[str, Any]] | None: + """Build activity stream events from conversation history. + + Pairs assistant ``tool_calls`` with their corresponding ``tool``-role + result messages to synthesize TOOL_RESULT-like payloads for the + activity stream. Also includes assistant messages that carry + reasoning or tool_calls (for the "Calling …" cards). + """ + ctx = self._ctx + if not ctx: + return None + try: + raw = [m.to_dict() for m in ctx.conversation_history] + + # Index tool-role results by tool_call_id for fast lookup + tool_results: dict[str, dict[str, Any]] = {} + for d in raw: + if d.get("role") == "tool" and d.get("tool_call_id"): + tool_results[d["tool_call_id"]] = d + + events: list[dict[str, Any]] = [] + for d in raw: + role = d.get("role", "") + if role == "assistant": + tool_calls = d.get("tool_calls") or [] + reasoning = d.get("reasoning_content") + + # Emit a CONVERSATION_MESSAGE event for reasoning / tool_calls + if reasoning or tool_calls: + events.append( + { + "type": "CONVERSATION_MESSAGE", + "payload": { + "role": "assistant", + "content": d.get("content") or "", + "tool_calls": tool_calls or None, + "reasoning": reasoning, + }, + } + ) + + # For each tool_call, try to pair with its result + for tc in tool_calls: + call_id = tc.get("id", "") + fn = tc.get("function") or {} + tool_name = fn.get("name", "") + args_str = fn.get("arguments", "") + try: + arguments = ( + _json.loads(args_str) + if isinstance(args_str, str) and args_str + else args_str + ) + except _json.JSONDecodeError: + arguments = args_str + + # Look up the matching tool result + tr = tool_results.get(call_id) + result_content = (tr.get("content") or "") if tr else None + + events.append( + { + "type": "TOOL_RESULT", + "payload": { + "tool_name": tool_name, + "server_name": "", + "agent_id": "default", + "call_id": call_id, + "timestamp": None, + "duration_ms": None, + "result": result_content, + "error": None, + "success": True, + "arguments": self._serialise(arguments) + if arguments + else None, + }, + } + ) + + return events if events else None + except Exception as exc: + logger.debug("Error building activity history: %s", exc) + return None + # ------------------------------------------------------------------ # # Helpers # # ------------------------------------------------------------------ # + _SERIALISE_MAX_DEPTH = 20 + @staticmethod - def _serialise(value: Any) -> Any: + def _serialise(value: Any, _depth: int = 0) -> Any: """Convert a value to a JSON-safe representation.""" + if _depth > DashboardBridge._SERIALISE_MAX_DEPTH: + return "" if value is None or isinstance(value, (bool, int, float, str)): return value if isinstance(value, (list, tuple)): - return [DashboardBridge._serialise(v) for v in value] + return [DashboardBridge._serialise(v, _depth + 1) for v in value] if isinstance(value, dict): - return {k: DashboardBridge._serialise(v) for k, v in value.items()} + return { + k: DashboardBridge._serialise(v, _depth + 1) for k, v in value.items() + } # Objects with a to_dict / model_dump / __dict__ if hasattr(value, "to_dict"): try: - return DashboardBridge._serialise(value.to_dict()) - except Exception: - pass + return DashboardBridge._serialise(value.to_dict(), _depth + 1) + except Exception as exc: + logger.debug( + "_serialise to_dict() failed for %s: %s", type(value).__name__, exc + ) if hasattr(value, "model_dump"): try: - return DashboardBridge._serialise(value.model_dump()) - except Exception: - pass + return DashboardBridge._serialise(value.model_dump(), _depth + 1) + except Exception as exc: + logger.debug( + "_serialise model_dump() failed for %s: %s", + type(value).__name__, + exc, + ) return str(value) diff --git a/src/mcp_cli/dashboard/server.py b/src/mcp_cli/dashboard/server.py index 2be02f84..1832eb06 100644 --- a/src/mcp_cli/dashboard/server.py +++ b/src/mcp_cli/dashboard/server.py @@ -46,6 +46,13 @@ def __init__(self) -> None: self.on_browser_message: Callable[[dict[str, Any]], Any] | None = None # Called when a new WebSocket client connects (before message loop starts) self.on_client_connected: Callable[[Any], Any] | None = None + # Called when a WebSocket client disconnects + self.on_client_disconnected: Callable[[], Any] | None = None + + @property + def has_clients(self) -> bool: + """Return True if at least one browser client is connected.""" + return bool(self._clients) # ------------------------------------------------------------------ # # Public API # @@ -117,6 +124,13 @@ async def _ws_handler(self, ws: ServerConnection) -> None: "Dashboard WebSocket client disconnected (%d remain)", len(self._clients), ) + if self.on_client_disconnected is not None: + try: + result = self.on_client_disconnected() + if asyncio.iscoroutine(result): + await result + except Exception as exc: + logger.debug("Error in client disconnected callback: %s", exc) async def _handle_browser_message(self, raw: str) -> None: try: diff --git a/src/mcp_cli/dashboard/static/shell.html b/src/mcp_cli/dashboard/static/shell.html index 2065cf18..691f8297 100644 --- a/src/mcp_cli/dashboard/static/shell.html +++ b/src/mcp_cli/dashboard/static/shell.html @@ -108,12 +108,40 @@ .dropdown-item:hover { background: var(--dash-bg-hover); } .dropdown-item.active { color: var(--dash-accent); } +/* Toolbar selects (model/provider) */ +.tb-select { + background: var(--dash-bg); + border: 1px solid var(--dash-border); + color: var(--dash-fg); + padding: 2px 6px; + border-radius: var(--dash-radius); + font-size: 11px; + font-family: var(--dash-font-ui); + cursor: pointer; + max-width: 140px; + outline: none; +} +.tb-select:hover { border-color: var(--dash-accent); } +.tb-label { + font-size: 10px; + color: var(--dash-fg-muted); + text-transform: uppercase; + letter-spacing: .04em; +} +.tb-group { + display: flex; + align-items: center; + gap: 4px; +} + /* Settings panel */ #settings-panel { display: none; position: fixed; top: 40px; right: 8px; - width: 260px; + width: 320px; + max-height: calc(100vh - 56px); + overflow-y: auto; background: var(--dash-bg-surface); border: 1px solid var(--dash-border); border-radius: var(--dash-radius); @@ -125,14 +153,104 @@ #settings-panel h3 { font-size: 12px; font-weight: 600; margin-bottom: 8px; color: var(--dash-fg-muted); text-transform: uppercase; letter-spacing: .05em; } #settings-panel label { font-size: 12px; display: block; margin-bottom: 4px; } #settings-panel select { width: 100%; padding: 4px 6px; background: var(--dash-bg); border: 1px solid var(--dash-border); border-radius: var(--dash-radius); color: var(--dash-fg); font-size: 12px; } +#settings-panel .section { margin-bottom: 12px; padding-bottom: 12px; border-bottom: 1px solid var(--dash-border); } +#settings-panel .section:last-child { border-bottom: none; margin-bottom: 0; padding-bottom: 0; } +#settings-panel textarea { + width: 100%; padding: 6px 8px; background: var(--dash-bg); border: 1px solid var(--dash-border); + border-radius: var(--dash-radius); color: var(--dash-fg); font-family: var(--dash-font-mono); + font-size: 11px; line-height: 1.4; resize: vertical; min-height: 80px; max-height: 300px; +} +#settings-panel textarea:focus { border-color: var(--dash-accent); outline: none; } +#settings-panel .btn-row { display: flex; gap: 6px; margin-top: 6px; } +#settings-panel .btn-sm { + background: transparent; border: 1px solid var(--dash-border); color: var(--dash-fg); + padding: 3px 10px; border-radius: var(--dash-radius); cursor: pointer; font-size: 11px; + font-family: var(--dash-font-ui); +} +#settings-panel .btn-sm:hover { background: var(--dash-bg-hover); } +#settings-panel .btn-sm.primary { background: var(--dash-accent); color: var(--dash-bg); border-color: var(--dash-accent); } +#settings-panel .btn-sm.primary:hover { opacity: 0.9; } +.server-list { list-style: none; padding: 0; margin: 4px 0 0 0; } +.server-item { + display: flex; align-items: center; gap: 6px; padding: 4px 0; + font-size: 12px; border-bottom: 1px solid var(--dash-border); +} +.server-item:last-child { border-bottom: none; } +.server-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; } +.server-dot.on { background: var(--dash-success); } +.server-dot.off { background: var(--dash-error); } +.server-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.server-tools { font-size: 10px; color: var(--dash-fg-muted); } + +/* ── Session drawer ────────────────────────────────────────────── */ +#session-drawer { + display: none; + position: fixed; + top: 40px; left: 8px; + width: 300px; + max-height: calc(100vh - 56px); + overflow-y: auto; + background: var(--dash-bg-surface); + border: 1px solid var(--dash-border); + border-radius: var(--dash-radius); + z-index: 200; + padding: 12px; + box-shadow: 0 4px 16px rgba(0,0,0,0.5); +} +#session-drawer.open { display: block; } +#session-drawer h3 { + font-size: 12px; font-weight: 600; margin-bottom: 8px; + color: var(--dash-fg-muted); text-transform: uppercase; letter-spacing: .05em; + display: flex; align-items: center; justify-content: space-between; +} +.session-list { list-style: none; padding: 0; margin: 0; } +.session-item { + display: flex; align-items: center; gap: 6px; padding: 8px 6px; + font-size: 12px; border-bottom: 1px solid var(--dash-border); + cursor: pointer; border-radius: var(--dash-radius); transition: background 0.15s; +} +.session-item:last-child { border-bottom: none; } +.session-item:hover { background: var(--dash-bg-hover); } +.session-item.active { background: var(--dash-bg-hover); border-left: 2px solid var(--dash-accent); } +.session-info { flex: 1; min-width: 0; } +.session-id { font-family: var(--dash-font-mono); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.session-desc { font-size: 10px; color: var(--dash-fg-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.session-meta { font-size: 10px; color: var(--dash-fg-muted); display: flex; gap: 8px; margin-top: 2px; } +.session-actions { display: flex; gap: 4px; flex-shrink: 0; } +.session-actions button { + background: transparent; border: none; color: var(--dash-fg-muted); + cursor: pointer; font-size: 11px; padding: 2px 4px; border-radius: 3px; +} +.session-actions button:hover { color: var(--dash-fg); background: var(--dash-bg-hover); } +.session-actions button.delete:hover { color: var(--dash-error); } +.session-empty { color: var(--dash-fg-muted); font-size: 12px; text-align: center; padding: 16px 0; } /* ── Grid layout ────────────────────────────────────────────────── */ -#grid-root { +#grid-wrapper { flex: 1; + position: relative; + overflow: hidden; + min-height: 0; +} +#grid-root { + position: absolute; + top: 0; left: 0; right: 0; bottom: 0; display: flex; flex-direction: column; overflow: hidden; - position: relative; +} +#view-overlay { + position: absolute; + top: 0; left: 0; right: 0; bottom: 0; + pointer-events: none; + z-index: 5; + overflow: hidden; +} +#view-overlay iframe { + position: absolute; + pointer-events: auto; + border: none; + display: none; } .grid-row { display: flex; @@ -207,18 +325,12 @@ } .panel-btn:hover { background: var(--dash-bg-hover); color: var(--dash-fg); } -/* Panel body */ +/* Panel body (empty slot — iframes are positioned over it from #view-overlay) */ .panel-body { flex: 1; overflow: hidden; position: relative; } -.panel-body iframe { - width: 100%; - height: 100%; - border: none; - display: block; -} /* Panel placeholder */ .panel-placeholder { @@ -278,32 +390,103 @@ .toast.error { border-left: 3px solid var(--dash-error); } @keyframes slideIn { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:translateY(0); } } +/* Tool approval dialog */ +#approval-overlay { + display: none; + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.5); + z-index: 600; + align-items: center; + justify-content: center; +} +#approval-overlay.open { display: flex; } +#approval-dialog { + background: var(--dash-bg-surface); + border: 1px solid var(--dash-border); + border-radius: var(--dash-radius); + padding: 16px; + max-width: 500px; + width: 90%; + box-shadow: 0 4px 24px rgba(0,0,0,0.5); +} +#approval-dialog h3 { + font-size: 14px; + color: var(--dash-warning); + margin-bottom: 8px; +} +#approval-dialog .approval-tool { + font-family: var(--dash-font-mono); + font-size: 13px; + color: var(--dash-accent); + margin-bottom: 8px; +} +#approval-dialog .approval-args { + background: var(--dash-bg); + border: 1px solid var(--dash-border); + border-radius: var(--dash-radius); + padding: 8px; + font-family: var(--dash-font-mono); + font-size: 11px; + color: var(--dash-fg-muted); + max-height: 200px; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-all; + margin-bottom: 12px; +} +#approval-dialog .approval-btns { + display: flex; + gap: 8px; + justify-content: flex-end; +} +#approval-dialog .approval-btns button { + padding: 6px 16px; + border-radius: var(--dash-radius); + border: 1px solid var(--dash-border); + cursor: pointer; + font-size: 12px; + font-family: var(--dash-font-ui); +} +#approval-dialog .btn-approve { + background: var(--dash-success); + color: var(--dash-bg); + border-color: var(--dash-success); +} +#approval-dialog .btn-deny { + background: transparent; + color: var(--dash-error); + border-color: var(--dash-error); +} +#approval-dialog .btn-approve:hover { opacity: 0.9; } +#approval-dialog .btn-deny:hover { background: var(--dash-bg-hover); } + /* ── Responsive: stack panels vertically below 768px ─────────────── */ @media (max-width: 768px) { + #toolbar { flex-wrap: wrap; height: auto; min-height: 36px; padding: 4px 8px; gap: 6px; } + #toolbar .spacer { display: none; } + #model-group { order: 10; width: 100%; justify-content: center; } .grid-row { flex-direction: column; } .panel { min-width: unset; min-height: 150px; } .panel + .panel { border-left: none; border-top: 1px solid var(--dash-border); } .resize-handle-col { display: none; } + #settings-panel { width: 100%; right: 0; left: 0; top: auto; bottom: 0; max-height: 60vh; border-radius: var(--dash-radius) var(--dash-radius) 0 0; } + #session-drawer { width: 100%; right: 0; left: 0; top: auto; bottom: 0; max-height: 60vh; border-radius: var(--dash-radius) var(--dash-radius) 0 0; } + #approval-dialog { max-width: 95%; } + .toast { max-width: 90vw; } } -/* ── View stash: keeps iframes in document to preserve state ──────── */ -#view-stash { - position: fixed; - left: -10000px; - top: -10000px; - width: 1px; - height: 1px; - overflow: hidden; - opacity: 0; - pointer-events: none; +@media (max-width: 480px) { + #toolbar .brand span:last-child { display: none; } + .tb-select { max-width: 100px; font-size: 10px; } + .tb-btn { font-size: 11px; padding: 2px 6px; } } + +/* (view-stash removed — iframes now live permanently in #view-overlay) */ - - -
@@ -311,6 +494,9 @@ mcp-cli dashboard
+ + +
-

Settings

- - -
- +
+

Theme

+ +
+ +
+

System Prompt

+ +
+ + +
+
+ +
+

MCP Servers

+
    +
  • No servers connected
  • +
+
+ +
+ +
+ +
+

Conversation

+ +
- -
+ +
+

+ Sessions + +

+
    +
  • Loading sessions...
  • +
+
+ + +
+
+
+
+ + +
+
+

Tool Confirmation Required

+
+
+
+ + +
+
+
@@ -367,20 +619,38 @@

Settings

const popoutWindows = new Map(); // viewId → { win, intervalId } // ================================================================ -// WebSocket +// WebSocket (exponential backoff reconnect) // ================================================================ +let _wsBackoff = 1000; // current backoff delay (ms) +const _WS_BACKOFF_MAX = 30000; // cap at 30 s +let _wsReconnectTimer = null; + function connectWS() { + if (_wsReconnectTimer) { clearTimeout(_wsReconnectTimer); _wsReconnectTimer = null; } + // Close existing connection before creating a new one + if (ws) { try { ws.onclose = null; ws.close(); } catch(e) {} ws = null; } ws = new WebSocket(WS_URL); ws.onopen = () => { connected = true; + _wsBackoff = 1000; // reset on success document.getElementById('conn-dot').classList.add('connected'); + showToast('success', 'Connected to dashboard server', 2000); + // Request current config (model, servers, system prompt) + sendToBridge({ type: 'REQUEST_CONFIG' }); + // Request tool registry + sendToBridge({ type: 'REQUEST_TOOLS' }); }; ws.onclose = () => { + const wasConnected = connected; connected = false; document.getElementById('conn-dot').classList.remove('connected'); - setTimeout(connectWS, 5000); + if (wasConnected) showToast('warning', `Disconnected — reconnecting in ${Math.round(_wsBackoff/1000)}s…`, _wsBackoff); + _wsReconnectTimer = setTimeout(() => { + _wsBackoff = Math.min(_wsBackoff * 2, _WS_BACKOFF_MAX); + connectWS(); + }, _wsBackoff); }; ws.onerror = () => { @@ -403,10 +673,16 @@

Settings

// ================================================================ // Bridge message routing // ================================================================ +// Cached payloads for replaying to late-loading views +let _cachedToolRegistry = null; +let _cachedPlanUpdate = null; + function handleBridgeMessage(msg) { switch (msg.type) { case 'VIEW_REGISTRY': - viewRegistry = msg.views || []; + // Merge dynamic views from bridge without clobbering builtins + // Envelope format: msg.payload.views; legacy format: msg.views + mergeViewRegistry((msg.payload && msg.payload.views) || msg.views || []); rebuildAddPanelMenu(); break; @@ -420,12 +696,49 @@

Settings

case 'CONVERSATION_MESSAGE': broadcastToViewType('conversation', 'CONVERSATION_MESSAGE', msg.payload); + sendToActivityStream('CONVERSATION_MESSAGE', msg.payload); break; case 'CONVERSATION_TOKEN': broadcastToViewType('conversation', 'CONVERSATION_TOKEN', msg.payload); break; + case 'CONVERSATION_HISTORY': + broadcastToViewType('conversation', 'CONVERSATION_HISTORY', msg.payload); + break; + + case 'ACTIVITY_HISTORY': + sendToActivityStream('ACTIVITY_HISTORY', msg.payload); + break; + + case 'CONFIG_STATE': + handleConfigState(msg.payload); + broadcastToViews('CONFIG_STATE', msg.payload); + break; + + case 'TOOL_REGISTRY': + _cachedToolRegistry = msg.payload; + broadcastToViewType('tools', 'TOOL_REGISTRY', msg.payload); + break; + + case 'TOOL_APPROVAL_REQUEST': + handleToolApprovalRequest(msg.payload); + break; + + case 'PLAN_UPDATE': + _cachedPlanUpdate = msg.payload; + broadcastToViewType('plan', 'PLAN_UPDATE', msg.payload); + sendToActivityStream('PLAN_UPDATE', msg.payload); + break; + + case 'SESSION_STATE': + handleSessionState(msg.payload); + break; + + case 'SESSION_LIST': + handleSessionList(msg.payload); + break; + case 'THEME': applyTheme(msg.payload); break; @@ -438,11 +751,23 @@

Settings

// ================================================================ // View Pool — iframes persist across layout changes // ================================================================ +function mergeViewRegistry(dynamicViews) { + // Keep builtins, add/update dynamic views + const builtinIds = new Set(viewRegistry.filter(v => v.source === 'builtin').map(v => v.id)); + const merged = viewRegistry.filter(v => v.source === 'builtin'); + for (const v of dynamicViews) { + if (!builtinIds.has(v.id)) merged.push(v); + } + viewRegistry = merged; +} + function srcForView(viewId) { const vInfo = viewRegistry.find(v => v.id === viewId); if (vInfo && vInfo.url) return vInfo.url; if (viewId === 'builtin:agent-terminal') return '/views/agent-terminal.html'; if (viewId === 'builtin:activity-stream') return '/views/activity-stream.html'; + if (viewId === 'builtin:tool-browser') return '/views/tool-browser.html'; + if (viewId === 'builtin:plan-viewer') return '/views/plan-viewer.html'; return ''; } @@ -452,22 +777,23 @@

Settings

const iframe = document.createElement('iframe'); iframe.sandbox = 'allow-scripts allow-same-origin allow-forms'; iframe.allow = ''; - iframe.style.cssText = 'width:100%;height:100%;border:none;display:block;'; iframe.src = srcForView(viewId); iframe.dataset.viewId = viewId; view.iframe = iframe; viewPool.set(viewId, view); - // Park in stash immediately — iframe must stay in document to preserve browsing context - document.getElementById('view-stash').appendChild(iframe); + // Append to view-overlay — iframes NEVER move between parents (reparenting + // destroys browsing context in all modern browsers). They stay here + // permanently and are positioned over panel body slots via syncViewPositions(). + document.getElementById('view-overlay').appendChild(iframe); return view; } -function attachViewToSlot(viewId, bodyEl) { +function attachViewToSlot(viewId) { if (!viewId) return; const view = getOrCreateView(viewId); - // Detach from any current parent without destroying the iframe - if (view.iframe.parentNode) view.iframe.parentNode.removeChild(view.iframe); - bodyEl.appendChild(view.iframe); + // No reparenting — iframe stays in #view-overlay. + // syncViewPositions() will position it over the panel body. + requestAnimationFrame(() => syncViewPositions()); // Start ready timeout only for first load if (!view.ready && !view._readyTimeout) { view._readyTimeout = setTimeout(() => { @@ -521,16 +847,13 @@

Settings

function switchPanelView(panelId, newViewId) { const panel = panels[panelId]; if (!panel || panel.viewId === newViewId) return; - const body = panel.el.querySelector('.panel-body'); - if (!body) return; - // Detach current view (keeps it alive in pool) - if (panel.viewId) { - const oldView = viewPool.get(panel.viewId); - if (oldView?.iframe.parentNode === body) body.removeChild(oldView.iframe); - } + // Just update the mapping — no iframe DOM manipulation needed. + // The old view's iframe hides automatically via syncViewPositions() + // (it will no longer be hosted by any panel). panel.viewId = newViewId; - attachViewToSlot(newViewId, body); + getOrCreateView(newViewId); // ensure iframe exists updatePanelHeader(panel); + requestAnimationFrame(() => syncViewPositions()); // If already ready, send INIT with current dimensions const view = viewPool.get(newViewId); if (view?.ready) sendInitToView(newViewId, panel); @@ -571,6 +894,20 @@

Settings

} } +function sendToActivityStream(type, payload) { + // Send a message specifically to the activity stream view (embedded or popout) + for (const panel of Object.values(panels)) { + if (panel.viewId !== 'builtin:activity-stream') continue; + const view = viewPool.get(panel.viewId); + if (view?.ready && view.iframe) postToIframe(view.iframe, type, payload); + } + for (const [viewId, entry] of popoutWindows) { + if (viewId === 'builtin:activity-stream' && !entry.win.closed) { + postToPopout(entry.win, type, payload); + } + } +} + function broadcastToViewType(viewType, type, payload) { for (const panel of Object.values(panels)) { const view = viewPool.get(panel.viewId); @@ -672,12 +1009,37 @@

Settings

updatePanelHeader(panel); if (!wasReady) { sendInitToView(viewId, panel); + // Replay cached state to newly ready views + replayCachedState(viewId, view); } else { // View reattached to a slot — update its dimensions notifyResize(panel.panelId); } } +function replayCachedState(viewId, view) { + const vInfo = viewRegistry.find(v => v.id === viewId); + const viewType = vInfo?.type; + + // Replay TOOL_REGISTRY to tools-type views + if (viewType === 'tools' && _cachedToolRegistry) { + postToIframe(view.iframe, 'TOOL_REGISTRY', _cachedToolRegistry); + } + // Replay PLAN_UPDATE to plan-type views and activity stream + if (_cachedPlanUpdate && (viewType === 'plan' || viewId === 'builtin:activity-stream')) { + postToIframe(view.iframe, 'PLAN_UPDATE', _cachedPlanUpdate); + } + // Replay CONFIG_STATE to all views + if (_configState) { + postToIframe(view.iframe, 'CONFIG_STATE', _configState); + } + // Replay CONVERSATION_HISTORY to conversation-type views + if (viewType === 'conversation') { + // Request fresh history from bridge + sendToBridge({ type: 'REQUEST_CONFIG' }); + } +} + // ================================================================ // Theme management // ================================================================ @@ -711,7 +1073,7 @@

Settings

const root = document.documentElement; root.style.setProperty('--dash-bg', t.bg || ''); - root.style.setProperty('--dash-bg-surface', t.bg_surface || t.bg_surface || ''); + root.style.setProperty('--dash-bg-surface', t.bg_surface || ''); root.style.setProperty('--dash-bg-hover', t.bg_hover || ''); root.style.setProperty('--dash-fg', t.fg || ''); root.style.setProperty('--dash-fg-muted', t.fg_muted || ''); @@ -735,6 +1097,7 @@

Settings

broadcastToViews('THEME', themeToCSS(t)); } +let _themeChangeHandler = null; function buildThemeSelect() { const sel = document.getElementById('theme-select'); sel.innerHTML = ''; @@ -745,9 +1108,10 @@

Settings

sel.appendChild(opt); } sel.value = activeTheme; - sel.addEventListener('change', () => { - applyTheme(themes[sel.value] || {}); - }); + // Remove previous listener to avoid stacking + if (_themeChangeHandler) sel.removeEventListener('change', _themeChangeHandler); + _themeChangeHandler = () => { applyTheme(themes[sel.value] || {}); }; + sel.addEventListener('change', _themeChangeHandler); } // ================================================================ @@ -768,15 +1132,9 @@

Settings

} function renderLayout(layout) { - const root = document.getElementById('grid-root'); - const stash = document.getElementById('view-stash'); - // Move all pool iframes to the stash — they stay in the document so their - // browsing context (and all JS state) is preserved across layout changes. - for (const [, view] of viewPool) { - if (view.iframe && view.iframe.parentNode !== stash) { - stash.appendChild(view.iframe); - } - } + const root = document.getElementById('grid-root'); + // Iframes stay in #view-overlay — we only rebuild the panel grid. + // No iframe reparenting occurs, so browsing context is preserved. root.innerHTML = ''; panels = {}; @@ -794,6 +1152,9 @@

Settings

rowEl.appendChild(panelEl); }); }); + + // Position iframes over their panel body slots after browser layout + requestAnimationFrame(() => syncViewPositions()); } function parseFlex(pct) { @@ -831,7 +1192,7 @@

Settings

panels[panelId] = panel; if (resolvedViewId) { - attachViewToSlot(resolvedViewId, body); + attachViewToSlot(resolvedViewId); } else { const ph = document.createElement('div'); ph.className = 'panel-placeholder'; @@ -845,7 +1206,7 @@

Settings

if (btn) { e.stopPropagation(); const action = btn.dataset.action; - if (action === 'minimize') panelEl.classList.toggle('minimized'); + if (action === 'minimize') { panelEl.classList.toggle('minimized'); requestAnimationFrame(() => syncViewPositions()); } else if (action === 'close') closePanel(panelId); else if (action === 'popout') popoutPanel(panel); return; @@ -884,6 +1245,8 @@

Settings

if (!viewId) return '□'; if (viewId === 'builtin:agent-terminal') return '⌨'; if (viewId === 'builtin:activity-stream') return '◈'; + if (viewId === 'builtin:tool-browser') return '🔧'; + if (viewId === 'builtin:plan-viewer') return '📋'; const v = viewRegistry.find(v => v.id === viewId); return v?.icon || '◻'; } @@ -892,6 +1255,8 @@

Settings

if (!viewId) return 'Empty'; if (viewId === 'builtin:agent-terminal') return 'Agent Terminal'; if (viewId === 'builtin:activity-stream') return 'Activity Stream'; + if (viewId === 'builtin:tool-browser') return 'Tool Browser'; + if (viewId === 'builtin:plan-viewer') return 'Plan Viewer'; const v = viewRegistry.find(v => v.id === viewId); return v ? v.name : viewId; } @@ -902,16 +1267,14 @@

Settings

function closePanel(panelId) { const panel = panels[panelId]; if (!panel) return; - // Detach view without destroying — keeps it alive in pool - if (panel.viewId) { - const view = viewPool.get(panel.viewId); - if (view?.iframe.parentNode) view.iframe.parentNode.removeChild(view.iframe); - } + // Don't touch the iframe — it stays alive in #view-overlay. + // syncViewPositions() will hide it since no panel hosts it. const rowEl = panel.rowEl; panel.el.remove(); delete panels[panelId]; if (rowEl && rowEl.querySelectorAll('.panel').length === 0) rowEl.remove(); rebuildAddPanelMenu(); + requestAnimationFrame(() => syncViewPositions()); } function popoutPanel(panel) { @@ -956,22 +1319,14 @@

Settings

function swapPanels(aId, bId) { const a = panels[aId], b = panels[bId]; if (!a || !b || a.viewId === b.viewId) return; - const aBody = a.el.querySelector('.panel-body'); - const bBody = b.el.querySelector('.panel-body'); - const aViewId = a.viewId, bViewId = b.viewId; - // Detach both views first - for (const vId of [aViewId, bViewId]) { - if (!vId) continue; - const v = viewPool.get(vId); - if (v?.iframe.parentNode) v.iframe.parentNode.removeChild(v.iframe); - } - // Cross-attach - a.viewId = bViewId; - b.viewId = aViewId; - if (bViewId && aBody) attachViewToSlot(bViewId, aBody); - if (aViewId && bBody) attachViewToSlot(aViewId, bBody); + // Just swap the viewId mappings — no iframe DOM manipulation. + // syncViewPositions() repositions iframes over the correct panel bodies. + const tmp = a.viewId; + a.viewId = b.viewId; + b.viewId = tmp; updatePanelHeader(a); updatePanelHeader(b); + requestAnimationFrame(() => syncViewPositions()); } function showPanelError(bodyEl, msg) { @@ -979,9 +1334,8 @@

Settings

ph.className = 'panel-placeholder'; ph.style.color = 'var(--dash-error)'; ph.textContent = msg; - const existing = bodyEl.querySelector('iframe'); - if (existing) existing.replaceWith(ph); - else bodyEl.appendChild(ph); + bodyEl.innerHTML = ''; + bodyEl.appendChild(ph); } // ================================================================ @@ -1005,7 +1359,7 @@

Settings

const panelsBefore = Array.from(rowEl.children).slice(0, handleIdx).filter(c => c.classList.contains('panel')); const panelsAfter = Array.from(rowEl.children).slice(handleIdx + 1).filter(c => c.classList.contains('panel')); - panels_before = panelsBefore.at(-1); + panels_before = panelsBefore[panelsBefore.length - 1]; panels_after = panelsAfter[0]; if (!panels_before || !panels_after) return; @@ -1018,6 +1372,7 @@

Settings

const na = Math.max(200, startAfter - dx); panels_before.style.flex = `0 0 ${nb}px`; panels_after.style.flex = `0 0 ${na}px`; + syncViewPositions(); notifyResize(panels_before.dataset.panelId); notifyResize(panels_after.dataset.panelId); } @@ -1037,6 +1392,7 @@

Settings

Array.from(rowEl.children).filter(c => c.classList.contains('panel')).forEach(p => { p.style.flex = ''; }); + requestAnimationFrame(() => syncViewPositions()); }); return handle; @@ -1058,7 +1414,7 @@

Settings

const rowsBefore = Array.from(root.children).slice(0, handleIdx).filter(c => c.classList.contains('grid-row')); const rowsAfter = Array.from(root.children).slice(handleIdx + 1).filter(c => c.classList.contains('grid-row')); - rowBefore = rowsBefore.at(-1); + rowBefore = rowsBefore[rowsBefore.length - 1]; rowAfter = rowsAfter[0]; if (!rowBefore || !rowAfter) return; @@ -1071,6 +1427,7 @@

Settings

const na = Math.max(150, startAfter - dy); rowBefore.style.flex = `0 0 ${nb}px`; rowAfter.style.flex = `0 0 ${na}px`; + syncViewPositions(); } function onUp() { @@ -1087,6 +1444,7 @@

Settings

Array.from(root.children).filter(c => c.classList.contains('grid-row')).forEach(r => { r.style.flex = ''; }); + requestAnimationFrame(() => syncViewPositions()); }); return handle; @@ -1102,6 +1460,45 @@

Settings

postToIframe(view.iframe, 'RESIZE', { width: body.clientWidth, height: body.clientHeight }); } +// ================================================================ +// View overlay positioning — positions iframes over panel body slots +// ================================================================ +function syncViewPositions() { + const overlay = document.getElementById('view-overlay'); + if (!overlay) return; + const overlayRect = overlay.getBoundingClientRect(); + + // Build set of viewIds currently hosted by a visible panel + const hostedViews = new Set(); + for (const panel of Object.values(panels)) { + if (panel.viewId) hostedViews.add(panel.viewId); + } + + for (const [viewId, view] of viewPool) { + if (!view.iframe) continue; + const panel = findPanelHostingView(viewId); + if (panel && !panel.el.classList.contains('minimized')) { + const body = panel.el.querySelector('.panel-body'); + if (body) { + const rect = body.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + view.iframe.style.display = 'block'; + view.iframe.style.left = (rect.left - overlayRect.left) + 'px'; + view.iframe.style.top = (rect.top - overlayRect.top) + 'px'; + view.iframe.style.width = rect.width + 'px'; + view.iframe.style.height = rect.height + 'px'; + continue; + } + } + } + // Not placed, minimized, or zero-size — hide + view.iframe.style.display = 'none'; + } +} + +// Re-sync on window resize +window.addEventListener('resize', () => requestAnimationFrame(() => syncViewPositions())); + // ================================================================ // Layout dropdown // ================================================================ @@ -1127,7 +1524,7 @@

Settings

const presets = { 'Minimal': { rows: [{ height: '100%', columns: [{ width: '100%', view: 'builtin:agent-terminal' }] }] }, 'Standard': { rows: [{ height: '100%', columns: [{ width: '70%', view: 'builtin:agent-terminal' }, { width: '30%', view: 'builtin:activity-stream' }] }] }, - 'Full': { rows: [{ height: '60%', columns: [{ width: '65%', view: 'builtin:agent-terminal' }, { width: '35%', view: 'builtin:activity-stream' }] }, { height: '40%', columns: [{ width: '100%', view: 'auto' }] }] }, + 'Full': { rows: [{ height: '60%', columns: [{ width: '65%', view: 'builtin:agent-terminal' }, { width: '35%', view: 'builtin:activity-stream' }] }, { height: '40%', columns: [{ width: '50%', view: 'builtin:tool-browser' }, { width: '50%', view: 'builtin:plan-viewer' }] }] }, }; const layout = presets[name]; if (layout) { layoutConfig = layout; renderLayout(layout); } @@ -1175,6 +1572,7 @@

Settings

panelEl.style.flex = '1'; lastRow.appendChild(panelEl); rebuildAddPanelMenu(); + requestAnimationFrame(() => syncViewPositions()); } // ================================================================ @@ -1189,6 +1587,45 @@

Settings

setTimeout(() => toast.remove(), duration || 5000); } +// ================================================================ +// Tool approval dialog +// ================================================================ +let _pendingApprovalCallId = null; +let _approvalQueue = []; + +function handleToolApprovalRequest(payload) { + // If another approval is already showing, deny it first (auto-deny stale) + if (_pendingApprovalCallId) { + sendToBridge({ type: 'TOOL_APPROVAL_RESPONSE', call_id: _pendingApprovalCallId, approved: false }); + } + _pendingApprovalCallId = payload.call_id || ''; + document.getElementById('approval-tool-name').textContent = payload.tool_name || 'unknown'; + try { + document.getElementById('approval-args').textContent = + JSON.stringify(payload.arguments, null, 2); + } catch { + document.getElementById('approval-args').textContent = String(payload.arguments || '{}'); + } + document.getElementById('approval-overlay').classList.add('open'); + showToast('warning', `Approval needed: ${payload.tool_name}`); +} + +document.getElementById('approval-approve').addEventListener('click', () => { + if (_pendingApprovalCallId) { + sendToBridge({ type: 'TOOL_APPROVAL_RESPONSE', call_id: _pendingApprovalCallId, approved: true }); + } + document.getElementById('approval-overlay').classList.remove('open'); + _pendingApprovalCallId = null; +}); + +document.getElementById('approval-deny').addEventListener('click', () => { + if (_pendingApprovalCallId) { + sendToBridge({ type: 'TOOL_APPROVAL_RESPONSE', call_id: _pendingApprovalCallId, approved: false }); + } + document.getElementById('approval-overlay').classList.remove('open'); + _pendingApprovalCallId = null; +}); + // ================================================================ // Toolbar event wiring // ================================================================ @@ -1224,12 +1661,350 @@

Settings

} }); -// Close all dropdowns/menus on outside click -document.addEventListener('click', () => { +document.getElementById('clear-history-btn').addEventListener('click', () => { + if (!confirm('Clear all chat history and start a new session?')) return; + sendToBridge({ type: 'CLEAR_HISTORY' }); + showToast('info', 'Chat history cleared'); +}); + +document.getElementById('new-session-btn').addEventListener('click', () => { + if (!confirm('Save current session and start a new one?')) return; + sendToBridge({ type: 'NEW_SESSION' }); + showToast('success', 'New session started'); + // Close session drawer if open + document.getElementById('session-drawer').classList.remove('open'); +}); + +document.getElementById('sessions-btn').addEventListener('click', (e) => { + e.stopPropagation(); + const drawer = document.getElementById('session-drawer'); + drawer.classList.toggle('open'); + if (drawer.classList.contains('open')) { + sendToBridge({ type: 'REQUEST_SESSIONS' }); + } +}); + +document.getElementById('refresh-sessions-btn').addEventListener('click', (e) => { + e.stopPropagation(); + sendToBridge({ type: 'REQUEST_SESSIONS' }); +}); + +// ================================================================ +// Session list management +// ================================================================ +let _sessionList = []; + +function handleSessionList(payload) { + _sessionList = payload.sessions || []; + const currentId = payload.current_session_id || _currentSessionId; + renderSessionList(_sessionList, currentId); +} + +function renderSessionList(sessions, currentId) { + const ul = document.getElementById('session-list'); + if (!sessions.length) { + ul.innerHTML = '
  • No saved sessions
  • '; + return; + } + ul.innerHTML = ''; + for (const s of sessions) { + const li = document.createElement('li'); + li.className = 'session-item' + (s.session_id === currentId ? ' active' : ''); + + const info = document.createElement('div'); + info.className = 'session-info'; + + const idEl = document.createElement('div'); + idEl.className = 'session-id'; + idEl.textContent = s.description || s.session_id.slice(0, 12); + info.appendChild(idEl); + + const meta = document.createElement('div'); + meta.className = 'session-meta'; + const msgs = document.createElement('span'); + msgs.textContent = s.message_count + ' msgs'; + meta.appendChild(msgs); + if (s.model) { + const model = document.createElement('span'); + model.textContent = s.model; + meta.appendChild(model); + } + const time = document.createElement('span'); + time.textContent = formatSessionTime(s.updated_at); + meta.appendChild(time); + info.appendChild(meta); + + li.appendChild(info); + + const actions = document.createElement('div'); + actions.className = 'session-actions'; + + if (s.session_id !== currentId) { + const loadBtn = document.createElement('button'); + loadBtn.textContent = 'Load'; + loadBtn.title = 'Switch to this session'; + loadBtn.addEventListener('click', (e) => { + e.stopPropagation(); + sendToBridge({ type: 'SWITCH_SESSION', session_id: s.session_id }); + showToast('info', 'Switching session...'); + }); + actions.appendChild(loadBtn); + + const delBtn = document.createElement('button'); + delBtn.textContent = 'Del'; + delBtn.className = 'delete'; + delBtn.title = 'Delete this session'; + delBtn.addEventListener('click', (e) => { + e.stopPropagation(); + if (confirm('Delete this session permanently?')) { + sendToBridge({ type: 'DELETE_SESSION', session_id: s.session_id }); + } + }); + actions.appendChild(delBtn); + } else { + const cur = document.createElement('span'); + cur.textContent = 'current'; + cur.style.cssText = 'font-size:10px;color:var(--dash-accent)'; + actions.appendChild(cur); + } + + li.appendChild(actions); + ul.appendChild(li); + } +} + +function formatSessionTime(iso) { + if (!iso) return ''; + try { + const d = new Date(iso); + const now = new Date(); + const diff = now - d; + if (diff < 60000) return 'just now'; + if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago'; + if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago'; + return d.toLocaleDateString(); + } catch { return ''; } +} + +// ================================================================ +// Config state (model, provider, servers, system prompt) +// ================================================================ +let _configState = null; // cached CONFIG_STATE payload +let _availableProviders = []; // [{name, models: [...]}] + +function handleConfigState(payload) { + _configState = payload; + _availableProviders = payload.available_providers || []; + updateProviderSelect(payload.provider); + updateModelSelect(payload.provider, payload.model); + updateServerList(payload.servers || []); + updateSystemPromptEditor(payload.system_prompt || ''); + // Show model group once we have config + document.getElementById('model-group').style.display = 'flex'; +} + +// ── Session state ───────────────────────────────────────────────── +let _currentSessionId = null; + +function handleSessionState(payload) { + _currentSessionId = payload.session_id || null; + // Could update a session indicator in toolbar later +} + +// ── Provider / Model selectors ──────────────────────────────────── +function updateProviderSelect(activeProvider) { + const sel = document.getElementById('provider-select'); + const prev = sel.value; + sel.innerHTML = ''; + for (const p of _availableProviders) { + const opt = document.createElement('option'); + opt.value = p.name; + opt.textContent = p.name; + sel.appendChild(opt); + } + sel.value = activeProvider || prev; +} + +function updateModelSelect(provider, activeModel) { + const sel = document.getElementById('model-select'); + sel.innerHTML = ''; + const pInfo = _availableProviders.find(p => p.name === provider); + const models = pInfo ? pInfo.models : []; + if (models.length === 0) { + const opt = document.createElement('option'); + opt.value = activeModel || ''; + opt.textContent = activeModel || '(unknown)'; + sel.appendChild(opt); + } else { + for (const m of models) { + const opt = document.createElement('option'); + opt.value = m; + opt.textContent = m; + sel.appendChild(opt); + } + } + sel.value = activeModel || ''; + // If active model not in list, add it + if (sel.value !== activeModel && activeModel) { + const opt = document.createElement('option'); + opt.value = activeModel; + opt.textContent = activeModel; + sel.insertBefore(opt, sel.firstChild); + sel.value = activeModel; + } +} + +document.getElementById('provider-select').addEventListener('change', (e) => { + const provider = e.target.value; + const pInfo = _availableProviders.find(p => p.name === provider); + const models = pInfo ? pInfo.models : []; + const model = models[0] || ''; + updateModelSelect(provider, model); + if (model) sendToBridge({ type: 'SWITCH_MODEL', provider, model }); +}); + +document.getElementById('model-select').addEventListener('change', (e) => { + const model = e.target.value; + const provider = document.getElementById('provider-select').value; + if (provider && model) sendToBridge({ type: 'SWITCH_MODEL', provider, model }); +}); + +// ── Server list ─────────────────────────────────────────────────── +function updateServerList(servers) { + const list = document.getElementById('server-list'); + list.innerHTML = ''; + if (!servers.length) { + list.innerHTML = '
  • No servers connected
  • '; + return; + } + for (const s of servers) { + const li = document.createElement('li'); + li.className = 'server-item'; + li.innerHTML = ` + + ${esc(s.name)} + ${esc(String(s.tool_count))} tools + `; + list.appendChild(li); + } +} + +function esc(s) { + return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); +} + +// ── System prompt editor ────────────────────────────────────────── +function updateSystemPromptEditor(prompt) { + const editor = document.getElementById('system-prompt-editor'); + // Only update if not focused (avoid overwriting user edits) + if (document.activeElement !== editor) { + editor.value = prompt; + } +} + +document.getElementById('apply-prompt-btn').addEventListener('click', () => { + const text = document.getElementById('system-prompt-editor').value; + sendToBridge({ type: 'UPDATE_SYSTEM_PROMPT', system_prompt: text }); + showToast('info', 'System prompt updated'); +}); + +document.getElementById('reset-prompt-btn').addEventListener('click', () => { + sendToBridge({ type: 'UPDATE_SYSTEM_PROMPT', system_prompt: '' }); + showToast('info', 'System prompt reset to default'); +}); + +// ================================================================ +// Export conversation +// ================================================================ +document.getElementById('export-btn').addEventListener('click', (e) => { + e.stopPropagation(); + document.getElementById('export-menu').classList.toggle('open'); +}); + +document.getElementById('export-menu').addEventListener('click', (e) => { + const item = e.target.closest('[data-format]'); + if (!item) return; + e.stopPropagation(); + document.getElementById('export-menu').classList.remove('open'); + exportConversation(item.dataset.format); +}); + +function exportConversation(format) { + // Collect messages from the agent-terminal iframe + const msgs = collectConversationMessages(); + if (!msgs.length) { showToast('warning', 'No messages to export'); return; } + + let content, ext, mime; + if (format === 'json') { + content = JSON.stringify({ exported: new Date().toISOString(), messages: msgs }, null, 2); + ext = 'json'; mime = 'application/json'; + } else { + const lines = [`# Conversation Export\n_${new Date().toISOString()}_\n`]; + for (const m of msgs) { + const label = m.role === 'user' ? '**You**' : m.role === 'assistant' ? '**Agent**' : `**${m.role}**`; + lines.push(`### ${label}\n${m.content}\n`); + } + content = lines.join('\n'); + ext = 'md'; mime = 'text/markdown'; + } + + const blob = new Blob([content], { type: mime }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `conversation-${Date.now()}.${ext}`; + a.click(); + URL.revokeObjectURL(url); + showToast('success', `Exported as ${ext.toUpperCase()}`); +} + +function collectConversationMessages() { + // Find the agent-terminal iframe and read its messages + for (const [viewId, view] of viewPool) { + if (viewId !== 'builtin:agent-terminal' || !view.iframe) continue; + try { + const doc = view.iframe.contentDocument || view.iframe.contentWindow.document; + const msgEls = doc.querySelectorAll('.msg'); + const messages = []; + for (const el of msgEls) { + const role = el.classList.contains('user') ? 'user' : + el.classList.contains('tool-call') ? 'tool' : 'assistant'; + const contentEl = el.querySelector('.msg-content'); + const toolNameEl = el.querySelector('.tool-name'); + let content = ''; + if (contentEl) content = contentEl.textContent || ''; + else if (toolNameEl) content = `[Tool: ${toolNameEl.textContent}]`; + if (content) messages.push({ role, content }); + } + return messages; + } catch (e) { + // Cross-origin — fall back to asking the bridge + sendToBridge({ type: 'REQUEST_EXPORT' }); + showToast('info', 'Export requested from server'); + return []; + } + } + return []; +} + +// Close dropdowns/menus on outside click (but NOT settings panel when clicking inside it) +document.addEventListener('click', (e) => { document.getElementById('layout-menu').classList.remove('open'); document.getElementById('add-panel-menu').classList.remove('open'); - document.getElementById('settings-panel').classList.remove('open'); + document.getElementById('export-menu').classList.remove('open'); document.querySelectorAll('.panel-view-menu.open').forEach(m => m.classList.remove('open')); + // Only close settings panel if click was outside both the panel and the settings button + const settingsPanel = document.getElementById('settings-panel'); + const settingsBtn = document.getElementById('settings-btn'); + if (!settingsPanel.contains(e.target) && e.target !== settingsBtn) { + settingsPanel.classList.remove('open'); + } + // Only close session drawer if click was outside both the drawer and the sessions button + const sessionDrawer = document.getElementById('session-drawer'); + const sessionsBtn = document.getElementById('sessions-btn'); + if (!sessionDrawer.contains(e.target) && e.target !== sessionsBtn) { + sessionDrawer.classList.remove('open'); + } }); // ================================================================ @@ -1243,6 +2018,8 @@

    Settings

    viewRegistry = [ { id: 'builtin:agent-terminal', name: 'Agent Terminal', source: 'builtin', icon: '⌨', type: 'conversation', url: '/views/agent-terminal.html' }, { id: 'builtin:activity-stream', name: 'Activity Stream', source: 'builtin', icon: '◈', type: 'stream', url: '/views/activity-stream.html' }, + { id: 'builtin:tool-browser', name: 'Tool Browser', source: 'builtin', icon: '🔧', type: 'tools', url: '/views/tool-browser.html' }, + { id: 'builtin:plan-viewer', name: 'Plan Viewer', source: 'builtin', icon: '📋', type: 'plan', url: '/views/plan-viewer.html' }, ]; rebuildAddPanelMenu(); diff --git a/src/mcp_cli/dashboard/static/views/activity-stream.html b/src/mcp_cli/dashboard/static/views/activity-stream.html index df108384..45969ec5 100644 --- a/src/mcp_cli/dashboard/static/views/activity-stream.html +++ b/src/mcp_cli/dashboard/static/views/activity-stream.html @@ -71,10 +71,15 @@ border-left-width: 3px; } .event-card:hover { background: var(--dash-bg-hover); } -.event-card.tool-ok { border-left-color: var(--dash-accent); } -.event-card.tool-err { border-left-color: var(--dash-error); } -.event-card.state-chg { border-left-color: var(--dash-fg-muted); } -.event-card.user-act { border-left-color: var(--dash-success); } +.event-card.tool-ok { border-left-color: var(--dash-accent); } +.event-card.tool-err { border-left-color: var(--dash-error); } +.event-card.state-chg { border-left-color: var(--dash-fg-muted); } +.event-card.user-act { border-left-color: var(--dash-success); } +.event-card.thinking { border-left-color: var(--dash-warning); } +.event-card.plan-run { border-left-color: var(--dash-info); } +.event-card.plan-done { border-left-color: var(--dash-success); } +.event-card.plan-fail { border-left-color: var(--dash-error); } +.event-card.msg-assist { border-left-color: var(--dash-accent); } .event-header { display: flex; @@ -90,22 +95,76 @@ .event-status.ok { color: var(--dash-success); } .event-status.err { color: var(--dash-error); } +/* Expandable detail section */ .event-detail { display: none; margin-top: 6px; - font-family: var(--dash-font-mono); font-size: 11px; color: var(--dash-fg-muted); - white-space: pre-wrap; - word-break: break-all; - max-height: 200px; + max-height: 300px; overflow-y: auto; background: var(--dash-bg); border-radius: var(--dash-radius); - padding: 6px; + padding: 8px; } .event-card.expanded .event-detail { display: block; } +/* Detail subsections */ +.detail-section { margin-bottom: 8px; } +.detail-section:last-child { margin-bottom: 0; } +.detail-label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + color: var(--dash-fg-muted); + margin-bottom: 3px; + letter-spacing: 0.5px; +} +.detail-content { + font-family: var(--dash-font-mono); + font-size: 11px; + white-space: pre-wrap; + word-break: break-all; + color: var(--dash-fg); + line-height: 1.4; +} +.detail-content.reasoning { + color: var(--dash-warning); + font-family: var(--dash-font-ui); + font-style: italic; + word-break: break-word; +} + +/* Plan steps in detail */ +.detail-step { + display: flex; + gap: 8px; + padding: 3px 0; + border-bottom: 1px solid var(--dash-border); + align-items: baseline; +} +.detail-step:last-child { border-bottom: none; } +.detail-step-icon { width: 14px; text-align: center; flex-shrink: 0; } +.detail-step-title { flex: 1; font-size: 11px; } +.detail-step-tool { + font-family: var(--dash-font-mono); + font-size: 10px; + color: var(--dash-accent); +} +.detail-step-status { font-size: 10px; } +.detail-step-status.ok { color: var(--dash-success); } +.detail-step-status.err { color: var(--dash-error); } +.detail-step-status.run { color: var(--dash-info); } + +/* Expand indicator */ +.expand-hint { + font-size: 10px; + color: var(--dash-fg-muted); + margin-top: 2px; + opacity: 0.6; +} +.event-card.expanded .expand-hint { display: none; } + /* Scroll-to-bottom button + badge */ #scroll-btn { display: none; @@ -182,6 +241,15 @@ case 'AGENT_STATE': addEvent('state', msg.payload); break; + case 'PLAN_UPDATE': + addEvent('plan', msg.payload); + break; + case 'CONVERSATION_MESSAGE': + addEvent('message', msg.payload); + break; + case 'ACTIVITY_HISTORY': + handleActivityHistory(msg.payload); + break; } }); @@ -198,7 +266,7 @@ shell.postMessage({ protocol: PROTOCOL, version: VERSION, type: 'READY', - payload: { name: 'Activity Stream', icon: 'activity', accepts: ['TOOL_RESULT', 'AGENT_STATE'], version: 1 } + payload: { name: 'Activity Stream', icon: 'activity', accepts: ['TOOL_RESULT', 'AGENT_STATE', 'PLAN_UPDATE', 'CONVERSATION_MESSAGE', 'ACTIVITY_HISTORY'], version: 1 } }, '*'); } @@ -219,6 +287,26 @@ // ── Events ────────────────────────────────────────────────────── function addEvent(kind, payload) { const ev = { kind, payload, ts: Date.now(), id: allEvents.length }; + + // For plan updates, replace existing entry for the same plan_id (running→complete) + if (kind === 'plan' && payload.plan_id) { + const existIdx = allEvents.findIndex(e => e.kind === 'plan' && e.payload.plan_id === payload.plan_id); + if (existIdx !== -1) { + allEvents[existIdx] = ev; + ev.id = existIdx; + rerender(); + return; + } + } + + // Skip state events where status is the same as the previous state event + if (kind === 'state') { + const lastState = allEvents.filter(e => e.kind === 'state').pop(); + if (lastState && lastState.payload.status === payload.status && lastState.payload.current_tool === payload.current_tool) { + return; // Deduplicate identical state transitions + } + } + allEvents.push(ev); if (kind === 'tool' && payload.server_name) { @@ -239,7 +327,7 @@ function matchesFilter(ev) { const p = ev.payload; if (filterStatus === 'ok' && p.success === false) return false; - if (filterStatus === 'err' && p.success !== false) return false; + if (filterStatus === 'err' && p.success !== false && ev.kind === 'tool') return false; if (filterServer && p.server_name !== filterServer) return false; if (filterText) { const haystack = JSON.stringify(p).toLowerCase(); @@ -260,43 +348,264 @@ } } +// ── Card builders by event kind ───────────────────────────────── function buildCard(ev) { - const p = ev.payload; + switch (ev.kind) { + case 'tool': return buildToolCard(ev); + case 'state': return buildStateCard(ev); + case 'plan': return buildPlanCard(ev); + case 'message': return buildMessageCard(ev); + default: return buildGenericCard(ev); + } +} + +function buildToolCard(ev) { + const p = ev.payload; const card = document.createElement('div'); const ok = p.success !== false; - let cls = 'event-card '; - if (ev.kind === 'tool') cls += ok ? 'tool-ok' : 'tool-err'; - if (ev.kind === 'state') cls += 'state-chg'; - card.className = cls; + card.className = `event-card ${ok ? 'tool-ok' : 'tool-err'}`; card.dataset.evId = ev.id; - const tsStr = new Date(ev.ts).toLocaleTimeString(); + const tsStr = new Date(ev.ts).toLocaleTimeString(); const durStr = p.duration_ms != null ? `${p.duration_ms}ms` : ''; + const summary = summariseResult(p.result); + + // Build structured detail + let detailHtml = ''; + + // Arguments section + if (p.arguments && Object.keys(p.arguments).length > 0) { + detailHtml += `
    +
    Arguments
    +
    ${esc(JSON.stringify(p.arguments, null, 2))}
    +
    `; + } + + // Result section + if (p.result != null) { + const resultStr = typeof p.result === 'string' ? p.result : JSON.stringify(p.result, null, 2); + detailHtml += `
    +
    Result
    +
    ${esc(resultStr)}
    +
    `; + } + + // Error section + if (p.error) { + detailHtml += `
    +
    Error
    +
    ${esc(p.error)}
    +
    `; + } + + const hasDetail = detailHtml.length > 0; + + card.innerHTML = ` +
    + ${esc(p.tool_name || '')} + ${esc(p.server_name || '')} + ${esc(durStr)} +
    +
    + ${ok ? '✓' : '✗'} ${ok ? summary : esc(p.error || 'error')} + ${tsStr} +
    + ${hasDetail ? '
    click to expand
    ' : ''} +
    ${detailHtml}
    `; + + if (hasDetail) card.addEventListener('click', () => card.classList.toggle('expanded')); + return card; +} + +function buildStateCard(ev) { + const p = ev.payload; + const card = document.createElement('div'); + const tsStr = new Date(ev.ts).toLocaleTimeString(); + + const status = p.status || 'unknown'; + const isThinking = status === 'thinking'; + + card.className = `event-card ${isThinking ? 'thinking' : 'state-chg'}`; + card.dataset.evId = ev.id; + + const icon = isThinking ? '💡' : (status === 'tool_calling' ? '⚙' : '◎'); + const tool = p.current_tool ? ` → ${esc(p.current_tool)}` : ''; + const tokens = p.tokens_used ? ` (${p.tokens_used} tokens)` : ''; + + card.innerHTML = ` +
    + ${icon} ${esc(status)}${tool} + ${tsStr}${tokens} +
    `; + + return card; +} + +function buildPlanCard(ev) { + const p = ev.payload; + const card = document.createElement('div'); + const tsStr = new Date(ev.ts).toLocaleTimeString(); + + const planStatus = p.status || 'running'; + const isRunning = planStatus === 'running'; + const isComplete = planStatus === 'complete'; + const isFailed = planStatus === 'failed'; + + card.className = `event-card ${isFailed ? 'plan-fail' : (isComplete ? 'plan-done' : 'plan-run')}`; + card.dataset.evId = ev.id; + + const steps = p.steps || []; + const completed = steps.filter(s => s.status === 'complete' || s.status === 'skipped').length; + const failed = steps.filter(s => s.status === 'failed').length; + const total = steps.length; + + const icon = isRunning ? '⚙' : (isComplete ? '✓' : '✗'); + const statusColor = isFailed ? 'var(--dash-error)' : (isComplete ? 'var(--dash-success)' : 'var(--dash-info)'); + const progress = total > 0 ? ` ${completed}/${total} steps` : ''; + + // Build steps detail + let detailHtml = ''; + if (steps.length > 0) { + let stepsHtml = ''; + for (const s of steps) { + const st = s.status || 'pending'; + let sIcon = '○'; // ○ + let sCls = ''; + if (st === 'running') { sIcon = '◎'; sCls = 'run'; } + if (st === 'complete') { sIcon = '✓'; sCls = 'ok'; } + if (st === 'failed') { sIcon = '✗'; sCls = 'err'; } + if (st === 'skipped') { sIcon = '−'; sCls = ''; } + + stepsHtml += `
    + ${sIcon} + ${esc(s.title || 'Step ' + (s.index || ''))} + ${s.tool ? `${esc(s.tool)}` : ''} + ${esc(st)} +
    `; + } + detailHtml += `
    +
    Steps
    + ${stepsHtml} +
    `; + } + + if (p.error) { + detailHtml += `
    +
    Error
    +
    ${esc(p.error)}
    +
    `; + } + + const hasDetail = detailHtml.length > 0; + + card.innerHTML = ` +
    + ${icon} Plan: ${esc(p.title || 'Untitled')} + ${progress} +
    +
    + ${esc(planStatus)} + ${tsStr} +
    + ${hasDetail ? '
    click to expand
    ' : ''} +
    ${detailHtml}
    `; + + if (hasDetail) card.addEventListener('click', () => card.classList.toggle('expanded')); + return card; +} + +function buildMessageCard(ev) { + const p = ev.payload; + const card = document.createElement('div'); + const tsStr = new Date(ev.ts).toLocaleTimeString(); + + const role = p.role || 'unknown'; + const hasReasoning = !!(p.reasoning); + const hasToolCalls = !!(p.tool_calls && p.tool_calls.length > 0); + + // Only show thinking/reasoning cards — assistant text goes to terminal + if (role === 'assistant' && hasReasoning) { + card.className = 'event-card thinking'; + card.dataset.evId = ev.id; + + const preview = p.reasoning.slice(0, 80) + (p.reasoning.length > 80 ? '…' : ''); + + let detailHtml = `
    +
    Reasoning
    +
    ${esc(p.reasoning)}
    +
    `; + + if (p.content) { + const contentPreview = typeof p.content === 'string' ? p.content.slice(0, 200) : JSON.stringify(p.content).slice(0, 200); + detailHtml += `
    +
    Response
    +
    ${esc(contentPreview)}${p.content.length > 200 ? '…' : ''}
    +
    `; + } - if (ev.kind === 'tool') { - const summary = summariseResult(p.result); card.innerHTML = `
    - 🔧 ${esc(p.tool_name || '')} - ${esc(p.server_name || '')} - ${esc(durStr)} + 💡 Thinking + ${tsStr}
    - ${ok ? '✓' : '✗'} ${ok ? summary : esc(p.error || 'error')} - ${tsStr} + ${esc(preview)}
    -
    ${esc(JSON.stringify(p, null, 2))}
    `; - } else { - const status = p.status || 'unknown'; - const tool = p.current_tool ? ` → ${p.current_tool}` : ''; +
    click to expand
    +
    ${detailHtml}
    `; + + card.addEventListener('click', () => card.classList.toggle('expanded')); + return card; + } + + // For non-reasoning messages, only show if they have tool_calls info + if (role === 'assistant' && hasToolCalls) { + card.className = 'event-card msg-assist'; + card.dataset.evId = ev.id; + + const toolNames = p.tool_calls.map(tc => tc.function?.name || tc.name || '?').join(', '); + + let detailHtml = ''; + for (const tc of p.tool_calls) { + const name = tc.function?.name || tc.name || '?'; + const args = tc.function?.arguments || tc.arguments || {}; + const argsStr = typeof args === 'string' ? args : JSON.stringify(args, null, 2); + detailHtml += `
    +
    ${esc(name)}
    +
    ${esc(argsStr)}
    +
    `; + } + card.innerHTML = `
    - ◎ Agent: ${esc(status)}${esc(tool)} + ⚙ Calling: ${esc(toolNames)} ${tsStr} -
    `; +
    +
    click to expand
    +
    ${detailHtml}
    `; + + card.addEventListener('click', () => card.classList.toggle('expanded')); + return card; } + // Skip other message types (user input, plain assistant text without reasoning) + // Return an empty non-rendered card + card.style.display = 'none'; + return card; +} + +function buildGenericCard(ev) { + const card = document.createElement('div'); + card.className = 'event-card state-chg'; + card.dataset.evId = ev.id; + const tsStr = new Date(ev.ts).toLocaleTimeString(); + card.innerHTML = ` +
    + ◎ ${esc(ev.kind)} + ${tsStr} +
    +
    ${esc(JSON.stringify(ev.payload, null, 2))}
    `; card.addEventListener('click', () => card.classList.toggle('expanded')); return card; } @@ -376,6 +685,34 @@ } } +// ── Activity history replay (session load) ─────────────────── +function handleActivityHistory(payload) { + const events = payload.events || []; + // Clear current state and replay + allEvents = []; + servers = new Set(); + eventsEl.innerHTML = ''; + autoScroll = true; + newCount = 0; + + // Rebuild server filter options + serverSel.innerHTML = ''; + + for (const ev of events) { + // Each event has { type: 'TOOL_RESULT'|'CONVERSATION_MESSAGE', payload: {...} } + const kind = ev.type === 'TOOL_RESULT' ? 'tool' + : ev.type === 'CONVERSATION_MESSAGE' ? 'message' + : ev.type === 'PLAN_UPDATE' ? 'plan' + : ev.type === 'AGENT_STATE' ? 'state' + : 'unknown'; + if (kind === 'unknown') continue; + addEvent(kind, ev.payload); + } + + // Scroll to bottom after replay + wrapper.scrollTop = wrapper.scrollHeight; +} + // Send READY immediately (shell will send INIT back) sendReady(); diff --git a/src/mcp_cli/dashboard/static/views/agent-terminal.html b/src/mcp_cli/dashboard/static/views/agent-terminal.html index 893890ce..63381950 100644 --- a/src/mcp_cli/dashboard/static/views/agent-terminal.html +++ b/src/mcp_cli/dashboard/static/views/agent-terminal.html @@ -153,6 +153,48 @@ } .reasoning-block.expanded .reasoning-content { display: block; } +/* Search bar */ +#search-bar { + display: none; + position: absolute; + top: 4px; + right: 12px; + z-index: 20; + background: var(--dash-bg-surface); + border: 1px solid var(--dash-border); + border-radius: var(--dash-radius); + padding: 4px 8px; + gap: 6px; + align-items: center; + box-shadow: 0 2px 8px rgba(0,0,0,0.3); +} +#search-bar.open { display: flex; } +#search-bar input { + background: var(--dash-bg); + border: 1px solid var(--dash-border); + border-radius: 3px; + color: var(--dash-fg); + font-size: 12px; + padding: 2px 6px; + width: 180px; + outline: none; + font-family: var(--dash-font-ui); +} +#search-bar input:focus { border-color: var(--dash-accent); } +#search-bar .search-count { font-size: 11px; color: var(--dash-fg-muted); min-width: 40px; text-align: center; } +#search-bar button { + background: transparent; + border: 1px solid var(--dash-border); + border-radius: 3px; + color: var(--dash-fg-muted); + cursor: pointer; + padding: 1px 6px; + font-size: 12px; +} +#search-bar button:hover { background: var(--dash-bg-hover); color: var(--dash-fg); } +.search-highlight { background: rgba(255, 200, 50, 0.3); border-radius: 2px; } +.search-highlight.current { background: rgba(255, 200, 50, 0.6); } + /* Scroll-to-bottom */ #scroll-btn { display: none; @@ -240,6 +282,13 @@
    +
    @@ -297,9 +346,15 @@ case 'CONVERSATION_TOKEN': handleToken(msg.payload); break; + case 'CONVERSATION_HISTORY': + handleConversationHistory(msg.payload); + break; case 'AGENT_STATE': updateStatus(msg.payload); break; + case 'CONFIG_STATE': + handleConfigState(msg.payload); + break; case 'TOOL_RESULT': // Tool results are shown inline as tool cards // (the assistant message with tool_calls is sent separately) @@ -320,7 +375,7 @@ shell.postMessage({ protocol: PROTOCOL, version: VERSION, type: 'READY', - payload: { name: 'Agent Terminal', icon: 'terminal', accepts: ['CONVERSATION_MESSAGE', 'CONVERSATION_TOKEN', 'AGENT_STATE'], version: 1 } + payload: { name: 'Agent Terminal', icon: 'terminal', accepts: ['CONVERSATION_MESSAGE', 'CONVERSATION_TOKEN', 'CONVERSATION_HISTORY', 'AGENT_STATE', 'CONFIG_STATE'], version: 1 } }, '*'); } @@ -344,6 +399,10 @@ if (streamingBubble) finaliseStreaming(); const { role, content, streaming, tool_calls, reasoning } = payload; + + // Tool-role messages are raw results — they belong in the activity stream, not chat + if (role === 'tool') return; + const wasTokenStreamed = _hadStreamingTokens; _hadStreamingTokens = false; @@ -430,7 +489,16 @@ const contentEl = bubble.querySelector('.msg-content'); if (!contentEl) return; if (typeof marked !== 'undefined' && !streaming) { - try { contentEl.innerHTML = marked.parse(text); return; } catch (e) { + try { + const html = marked.parse(text); + // Sanitize: strip + + diff --git a/src/mcp_cli/dashboard/static/views/tool-browser.html b/src/mcp_cli/dashboard/static/views/tool-browser.html new file mode 100644 index 00000000..222f7ffa --- /dev/null +++ b/src/mcp_cli/dashboard/static/views/tool-browser.html @@ -0,0 +1,430 @@ + + + + + +Tool Browser + + + + + + +
    +
    Waiting for tool registry…
    +
    + + + + diff --git a/tests/chat/test_chat_context.py b/tests/chat/test_chat_context.py index ff9674e2..0827b254 100644 --- a/tests/chat/test_chat_context.py +++ b/tests/chat/test_chat_context.py @@ -1962,7 +1962,7 @@ def test_load_session_not_found_returns_false(self, monkeypatch): @pytest.mark.asyncio async def test_load_session_exception_returns_false(self, monkeypatch, tmp_path): - """load_session returns False when add_event (or similar) raises.""" + """load_session returns False when event injection raises.""" from unittest.mock import Mock from mcp_cli.chat.session_store import SessionData, SessionMetadata @@ -1986,8 +1986,11 @@ async def test_load_session_exception_returns_false(self, monkeypatch, tmp_path) mock_store.load.return_value = data ctx._session_store = mock_store - # load_session calls self.session.add_event which doesn't exist - # This triggers the except block -> returns False + # Make session._session.events raise on append to trigger the except block + mock_events = Mock() + mock_events.append.side_effect = RuntimeError("injection error") + ctx.session._session.events = mock_events + result = ctx.load_session("fake-session") assert result is False diff --git a/tests/chat/test_memory_integration.py b/tests/chat/test_memory_integration.py index c8d927ba..98771153 100644 --- a/tests/chat/test_memory_integration.py +++ b/tests/chat/test_memory_integration.py @@ -46,7 +46,7 @@ def print_tool_call(self, tool_name, raw_arguments): async def finish_tool_execution(self, result=None, success=True): pass - def do_confirm_tool_execution(self, tool_name, arguments): + async def do_confirm_tool_execution(self, tool_name, arguments): return True async def start_tool_execution(self, tool_name, arguments): diff --git a/tests/chat/test_tool_processor.py b/tests/chat/test_tool_processor.py index 544920c9..2e5aa2c8 100644 --- a/tests/chat/test_tool_processor.py +++ b/tests/chat/test_tool_processor.py @@ -55,7 +55,7 @@ async def finish_tool_execution(self, result=None, success=True): # Add async method that tool processor expects pass - def do_confirm_tool_execution(self, tool_name, arguments): + async def do_confirm_tool_execution(self, tool_name, arguments): # Mock confirmation - always return True for tests return True @@ -452,7 +452,7 @@ async def test_process_tool_calls_exception_in_call(): class DenyConfirmUIManager(DummyUIManager): """UI manager that denies tool confirmation.""" - def do_confirm_tool_execution(self, tool_name, arguments): + async def do_confirm_tool_execution(self, tool_name, arguments): return False @@ -483,7 +483,7 @@ async def test_cancelled_tool_still_gets_result_for_remaining(): class SelectiveDenyUI(DummyUIManager): """Denies the second tool call.""" - def do_confirm_tool_execution(self, tool_name, arguments): + async def do_confirm_tool_execution(self, tool_name, arguments): call_count[0] += 1 return call_count[0] <= 1 # Allow first, deny second diff --git a/tests/chat/test_tool_processor_extended.py b/tests/chat/test_tool_processor_extended.py index acdc9ede..e2ae0584 100644 --- a/tests/chat/test_tool_processor_extended.py +++ b/tests/chat/test_tool_processor_extended.py @@ -73,7 +73,7 @@ def print_tool_call(self, tool_name, raw_arguments): async def finish_tool_execution(self, result=None, success=True): self._finish_calls.append((result, success)) - def do_confirm_tool_execution(self, tool_name, arguments): + async def do_confirm_tool_execution(self, tool_name, arguments): return True async def start_tool_execution(self, tool_name, arguments): @@ -99,7 +99,7 @@ def print_tool_call(self, tool_name, raw_arguments): async def finish_tool_execution(self, result=None, success=True): pass - def do_confirm_tool_execution(self, tool_name, arguments): + async def do_confirm_tool_execution(self, tool_name, arguments): return True async def start_tool_execution(self, tool_name, arguments): @@ -109,7 +109,7 @@ async def start_tool_execution(self, tool_name, arguments): class ConfirmDenyUIManager(DummyUIManager): """UI manager that denies tool confirmation.""" - def do_confirm_tool_execution(self, tool_name, arguments): + async def do_confirm_tool_execution(self, tool_name, arguments): return False diff --git a/tests/chat/test_ui_manager_coverage.py b/tests/chat/test_ui_manager_coverage.py index 1b387f09..6ac68f2b 100644 --- a/tests/chat/test_ui_manager_coverage.py +++ b/tests/chat/test_ui_manager_coverage.py @@ -4,6 +4,7 @@ This file is separate from test_ui_manager.py (which tests the command completer). """ +import asyncio import signal import time import pytest @@ -301,54 +302,188 @@ def test_print_tool_call_no_op(self, ui_manager): class TestDoConfirmToolExecution: """Tests for do_confirm_tool_execution.""" - def test_confirm_yes(self, ui_manager): + @pytest.mark.asyncio + async def test_confirm_yes(self, ui_manager): with ( patch("mcp_cli.chat.ui_manager.output"), patch("builtins.input", return_value="y"), ): - result = ui_manager.do_confirm_tool_execution("fn", '{"x": 1}') + result = await ui_manager.do_confirm_tool_execution("fn", '{"x": 1}') assert result is True - def test_confirm_empty_default_yes(self, ui_manager): + @pytest.mark.asyncio + async def test_confirm_empty_default_yes(self, ui_manager): with ( patch("mcp_cli.chat.ui_manager.output"), patch("builtins.input", return_value=""), ): - result = ui_manager.do_confirm_tool_execution("fn", {"x": 1}) + result = await ui_manager.do_confirm_tool_execution("fn", {"x": 1}) assert result is True - def test_confirm_no(self, ui_manager): + @pytest.mark.asyncio + async def test_confirm_no(self, ui_manager): with ( patch("mcp_cli.chat.ui_manager.output"), patch("builtins.input", return_value="n"), ): - result = ui_manager.do_confirm_tool_execution("fn", {"x": 1}) + result = await ui_manager.do_confirm_tool_execution("fn", {"x": 1}) assert result is False - def test_confirm_invalid_json_string(self, ui_manager): + @pytest.mark.asyncio + async def test_confirm_invalid_json_string(self, ui_manager): with ( patch("mcp_cli.chat.ui_manager.output"), patch("builtins.input", return_value="yes"), ): - result = ui_manager.do_confirm_tool_execution("fn", "{not json") + result = await ui_manager.do_confirm_tool_execution("fn", "{not json") + assert result is True + + @pytest.mark.asyncio + async def test_confirm_none_args(self, ui_manager): + with ( + patch("mcp_cli.chat.ui_manager.output"), + patch("builtins.input", return_value="y"), + ): + result = await ui_manager.do_confirm_tool_execution("fn", None) + assert result is True + + @pytest.mark.asyncio + async def test_confirm_empty_string(self, ui_manager): + with ( + patch("mcp_cli.chat.ui_manager.output"), + patch("builtins.input", return_value="y"), + ): + result = await ui_manager.do_confirm_tool_execution("fn", "") + assert result is True + + +# =========================================================================== +# Dashboard confirmation path tests +# =========================================================================== + + +class TestDoConfirmDashboardPath: + """Tests for do_confirm_tool_execution routing to dashboard bridge.""" + + @pytest.mark.asyncio + async def test_routes_to_dashboard_when_clients_connected(self, ui_manager): + """When dashboard bridge has clients, use bridge for approval.""" + fut = asyncio.get_event_loop().create_future() + fut.set_result(True) + + bridge = MagicMock() + bridge.server = MagicMock() + bridge.server.has_clients = True + bridge.request_tool_approval = AsyncMock(return_value=fut) + ui_manager.context.dashboard_bridge = bridge + + result = await ui_manager.do_confirm_tool_execution("test_tool", {"x": 1}) + assert result is True + bridge.request_tool_approval.assert_awaited_once() + + @pytest.mark.asyncio + async def test_falls_back_to_terminal_when_no_clients(self, ui_manager): + """When dashboard has no clients, fall back to terminal input.""" + bridge = MagicMock() + bridge.server = MagicMock() + bridge.server.has_clients = False + ui_manager.context.dashboard_bridge = bridge + + with ( + patch("mcp_cli.chat.ui_manager.output"), + patch("builtins.input", return_value="y"), + ): + result = await ui_manager.do_confirm_tool_execution("test_tool", {"x": 1}) assert result is True + bridge.request_tool_approval.assert_not_called() + + @pytest.mark.asyncio + async def test_falls_back_to_terminal_when_no_bridge(self, ui_manager): + """When no dashboard bridge, fall back to terminal input.""" + ui_manager.context.dashboard_bridge = None - def test_confirm_none_args(self, ui_manager): with ( patch("mcp_cli.chat.ui_manager.output"), patch("builtins.input", return_value="y"), ): - result = ui_manager.do_confirm_tool_execution("fn", None) + result = await ui_manager.do_confirm_tool_execution("test_tool", {"x": 1}) assert result is True - def test_confirm_empty_string(self, ui_manager): + @pytest.mark.asyncio + async def test_dashboard_denial(self, ui_manager): + """Dashboard user denies the tool execution.""" + fut = asyncio.get_event_loop().create_future() + fut.set_result(False) + + bridge = MagicMock() + bridge.server = MagicMock() + bridge.server.has_clients = True + bridge.request_tool_approval = AsyncMock(return_value=fut) + ui_manager.context.dashboard_bridge = bridge + + result = await ui_manager.do_confirm_tool_execution("test_tool", {"x": 1}) + assert result is False + + @pytest.mark.asyncio + async def test_dashboard_timeout_returns_false(self, ui_manager): + """If dashboard approval times out, return False.""" + # Create a future that never resolves + fut = asyncio.get_event_loop().create_future() + + bridge = MagicMock() + bridge.server = MagicMock() + bridge.server.has_clients = True + bridge.request_tool_approval = AsyncMock(return_value=fut) + ui_manager.context.dashboard_bridge = bridge + + # Patch timeout to be very short for testing + with patch( + "mcp_cli.chat.ui_manager.asyncio.wait_for", side_effect=asyncio.TimeoutError + ): + result = await ui_manager.do_confirm_tool_execution("test_tool", {"x": 1}) + assert result is False + # Clean up + fut.cancel() + + @pytest.mark.asyncio + async def test_dashboard_exception_falls_back_to_terminal(self, ui_manager): + """If dashboard approval throws, fall back to terminal.""" + bridge = MagicMock() + bridge.server = MagicMock() + bridge.server.has_clients = True + bridge.request_tool_approval = AsyncMock( + side_effect=RuntimeError("connection lost") + ) + ui_manager.context.dashboard_bridge = bridge + with ( patch("mcp_cli.chat.ui_manager.output"), patch("builtins.input", return_value="y"), ): - result = ui_manager.do_confirm_tool_execution("fn", "") + result = await ui_manager.do_confirm_tool_execution("test_tool", {"x": 1}) assert result is True + @pytest.mark.asyncio + async def test_string_args_parsed_for_dashboard(self, ui_manager): + """String arguments should be JSON-parsed before sending to dashboard.""" + fut = asyncio.get_event_loop().create_future() + fut.set_result(True) + + bridge = MagicMock() + bridge.server = MagicMock() + bridge.server.has_clients = True + bridge.request_tool_approval = AsyncMock(return_value=fut) + ui_manager.context.dashboard_bridge = bridge + + await ui_manager.do_confirm_tool_execution("test_tool", '{"key": "value"}') + call_args = bridge.request_tool_approval.call_args + # Arguments should have been parsed from JSON string to dict + assert ( + call_args.kwargs.get("arguments") == {"key": "value"} + or call_args[1].get("arguments") == {"key": "value"} + or (len(call_args[0]) >= 2 and call_args[0][1] == {"key": "value"}) + ) + # =========================================================================== # Streaming support tests diff --git a/tests/dashboard/test_bridge.py b/tests/dashboard/test_bridge.py index a6528000..5e19fc5e 100644 --- a/tests/dashboard/test_bridge.py +++ b/tests/dashboard/test_bridge.py @@ -271,7 +271,7 @@ async def test_sends_registry_when_views_exist(self): ws.send.assert_awaited_once() sent = json.loads(ws.send.call_args[0][0]) assert sent["type"] == "VIEW_REGISTRY" - assert len(sent["views"]) == 1 + assert len(sent["payload"]["views"]) == 1 @pytest.mark.asyncio async def test_no_send_when_registry_empty(self): diff --git a/tests/dashboard/test_bridge_extended.py b/tests/dashboard/test_bridge_extended.py new file mode 100644 index 00000000..2f7b45fd --- /dev/null +++ b/tests/dashboard/test_bridge_extended.py @@ -0,0 +1,539 @@ +# tests/dashboard/test_bridge_extended.py +"""Extended tests for DashboardBridge — covers gaps identified in code review. + +Focus areas: + - on_shutdown / on_client_disconnected (pending approval cleanup) + - _serialise depth guard + - request_tool_approval return type and lifecycle + - Tool approval response handling edge cases + - on_tool_result with arguments parameter + - VIEW_REGISTRY envelope format consistency +""" + +from __future__ import annotations + +import asyncio +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_bridge(): + from mcp_cli.dashboard.bridge import DashboardBridge + from mcp_cli.dashboard.server import DashboardServer + + server = MagicMock(spec=DashboardServer) + server.broadcast = AsyncMock() + server.on_browser_message = None + server.on_client_connected = None + server.on_client_disconnected = None + server.has_clients = True + bridge = DashboardBridge(server) + return bridge, server + + +# --------------------------------------------------------------------------- +# on_shutdown +# --------------------------------------------------------------------------- + + +class TestOnShutdown: + @pytest.mark.asyncio + async def test_cancels_pending_futures(self): + bridge, server = _make_bridge() + fut = asyncio.get_running_loop().create_future() + bridge._pending_approvals["call-1"] = fut + await bridge.on_shutdown() + assert fut.done() + assert fut.result() is False + assert len(bridge._pending_approvals) == 0 + + @pytest.mark.asyncio + async def test_shutdown_with_no_pending_is_safe(self): + bridge, server = _make_bridge() + await bridge.on_shutdown() + assert len(bridge._pending_approvals) == 0 + + @pytest.mark.asyncio + async def test_already_done_futures_not_modified(self): + bridge, server = _make_bridge() + fut = asyncio.get_running_loop().create_future() + fut.set_result(True) + bridge._pending_approvals["call-1"] = fut + await bridge.on_shutdown() + # Should still be True (not overwritten to False) + assert fut.result() is True + + +# --------------------------------------------------------------------------- +# on_client_disconnected +# --------------------------------------------------------------------------- + + +class TestOnClientDisconnected: + @pytest.mark.asyncio + async def test_cancels_pending_when_no_clients(self): + bridge, server = _make_bridge() + server.has_clients = False + fut = asyncio.get_running_loop().create_future() + bridge._pending_approvals["call-1"] = fut + await bridge.on_client_disconnected() + assert fut.done() + assert fut.result() is False + + @pytest.mark.asyncio + async def test_keeps_pending_when_clients_remain(self): + bridge, server = _make_bridge() + server.has_clients = True + fut = asyncio.get_running_loop().create_future() + bridge._pending_approvals["call-1"] = fut + await bridge.on_client_disconnected() + assert not fut.done() + assert len(bridge._pending_approvals) == 1 + # Clean up + fut.cancel() + + +# --------------------------------------------------------------------------- +# _serialise depth guard +# --------------------------------------------------------------------------- + + +class TestSerialiseDepth: + def test_max_depth_exceeded(self): + from mcp_cli.dashboard.bridge import DashboardBridge + + # Build deeply nested dict + obj: dict = {} + current = obj + for i in range(25): + current["nested"] = {} + current = current["nested"] + current["value"] = "deep" + + result = DashboardBridge._serialise(obj) + # Should hit depth limit and return placeholder string + serialized = json.dumps(result) + assert "" in serialized + + def test_normal_depth_works(self): + from mcp_cli.dashboard.bridge import DashboardBridge + + obj = {"a": {"b": {"c": "value"}}} + result = DashboardBridge._serialise(obj) + assert result == {"a": {"b": {"c": "value"}}} + + def test_circular_object_handled(self): + from mcp_cli.dashboard.bridge import DashboardBridge + + class Circular: + def to_dict(self): + return {"self": self} + + result = DashboardBridge._serialise(Circular()) + # Should eventually hit depth limit or fall through to str() + serialized = json.dumps(result) + assert serialized # Just ensure it doesn't crash + + +# --------------------------------------------------------------------------- +# request_tool_approval +# --------------------------------------------------------------------------- + + +class TestRequestToolApproval: + @pytest.mark.asyncio + async def test_returns_future(self): + bridge, server = _make_bridge() + fut = await bridge.request_tool_approval("test_tool", {"x": 1}, "call-1") + assert isinstance(fut, asyncio.Future) + assert "call-1" in bridge._pending_approvals + # Clean up + fut.cancel() + + @pytest.mark.asyncio + async def test_broadcasts_approval_request(self): + bridge, server = _make_bridge() + fut = await bridge.request_tool_approval("test_tool", {"x": 1}, "call-1") + server.broadcast.assert_awaited_once() + msg = server.broadcast.call_args[0][0] + assert msg["type"] == "TOOL_APPROVAL_REQUEST" + assert msg["payload"]["tool_name"] == "test_tool" + assert msg["payload"]["call_id"] == "call-1" + # Clean up + fut.cancel() + + @pytest.mark.asyncio + async def test_approval_response_resolves_future(self): + bridge, server = _make_bridge() + fut = await bridge.request_tool_approval("test_tool", {}, "call-1") + await bridge._handle_tool_approval_response( + {"call_id": "call-1", "approved": True} + ) + assert fut.done() + assert fut.result() is True + + @pytest.mark.asyncio + async def test_denial_response_resolves_future(self): + bridge, server = _make_bridge() + fut = await bridge.request_tool_approval("test_tool", {}, "call-1") + await bridge._handle_tool_approval_response( + {"call_id": "call-1", "approved": False} + ) + assert fut.done() + assert fut.result() is False + + @pytest.mark.asyncio + async def test_unknown_call_id_ignored(self): + bridge, server = _make_bridge() + # No pending approval — should not raise + await bridge._handle_tool_approval_response( + {"call_id": "nonexistent", "approved": True} + ) + + @pytest.mark.asyncio + async def test_response_for_done_future_ignored(self): + bridge, server = _make_bridge() + fut = await bridge.request_tool_approval("test_tool", {}, "call-1") + fut.set_result(False) # Already resolved + # Should not raise even though future is done + await bridge._handle_tool_approval_response( + {"call_id": "call-1", "approved": True} + ) + + +# --------------------------------------------------------------------------- +# on_tool_result with arguments +# --------------------------------------------------------------------------- + + +class TestOnToolResultArguments: + @pytest.mark.asyncio + async def test_arguments_included_in_payload(self): + bridge, server = _make_bridge() + await bridge.on_tool_result( + tool_name="read_file", + server_name="filesystem", + result="contents", + success=True, + arguments={"path": "/tmp/test.txt"}, + ) + msg = server.broadcast.call_args[0][0] + assert msg["payload"]["arguments"] == {"path": "/tmp/test.txt"} + + @pytest.mark.asyncio + async def test_arguments_none_when_not_provided(self): + bridge, server = _make_bridge() + await bridge.on_tool_result( + tool_name="read_file", + server_name="filesystem", + result="contents", + success=True, + ) + msg = server.broadcast.call_args[0][0] + assert msg["payload"]["arguments"] is None + + +# --------------------------------------------------------------------------- +# VIEW_REGISTRY envelope consistency +# --------------------------------------------------------------------------- + + +class TestViewRegistryEnvelope: + @pytest.mark.asyncio + async def test_broadcast_uses_envelope(self): + bridge, server = _make_bridge() + bridge._view_registry = [{"id": "test:view", "name": "Test"}] + await bridge.on_view_registry_update(bridge._view_registry) + msg = server.broadcast.call_args[0][0] + # Should be wrapped in envelope format + assert msg["protocol"] == "mcp-dashboard" + assert msg["version"] == 1 + assert msg["type"] == "VIEW_REGISTRY" + assert msg["payload"]["views"] == bridge._view_registry + + @pytest.mark.asyncio + async def test_client_connected_sends_envelope(self): + bridge, server = _make_bridge() + bridge._view_registry = [{"id": "test:view"}] + ws = AsyncMock() + await bridge._on_client_connected(ws) + ws.send.assert_awaited_once() + sent = json.loads(ws.send.call_args[0][0]) + assert sent["protocol"] == "mcp-dashboard" + assert sent["type"] == "VIEW_REGISTRY" + assert sent["payload"]["views"] == [{"id": "test:view"}] + + +# --------------------------------------------------------------------------- +# on_plan_update +# --------------------------------------------------------------------------- + + +class TestOnPlanUpdate: + @pytest.mark.asyncio + async def test_broadcasts_plan_update(self): + bridge, server = _make_bridge() + await bridge.on_plan_update( + plan_id="plan-1", + title="Test Plan", + steps=[{"index": 0, "title": "Step 1", "status": "running"}], + status="running", + ) + msg = server.broadcast.call_args[0][0] + assert msg["type"] == "PLAN_UPDATE" + assert msg["payload"]["plan_id"] == "plan-1" + assert msg["payload"]["title"] == "Test Plan" + assert len(msg["payload"]["steps"]) == 1 + assert msg["payload"]["status"] == "running" + + @pytest.mark.asyncio + async def test_plan_update_complete(self): + bridge, server = _make_bridge() + await bridge.on_plan_update( + plan_id="plan-1", + title="Done Plan", + steps=[{"index": 0, "title": "Step 1", "status": "complete"}], + status="complete", + ) + msg = server.broadcast.call_args[0][0] + assert msg["payload"]["status"] == "complete" + + @pytest.mark.asyncio + async def test_plan_update_with_error(self): + bridge, server = _make_bridge() + await bridge.on_plan_update( + plan_id="plan-1", + title="Failed Plan", + steps=[], + status="failed", + error="Something went wrong", + ) + msg = server.broadcast.call_args[0][0] + assert msg["payload"]["error"] == "Something went wrong" + + +# --------------------------------------------------------------------------- +# _build_conversation_history filtering +# --------------------------------------------------------------------------- + + +class TestBuildConversationHistory: + """Verify that tool-role and system-role messages are excluded from history.""" + + def _make_ctx_with_history(self, messages): + """Build a mock ChatContext with the given conversation_history.""" + + class FakeMsg: + def __init__(self, d): + self._d = d + + def to_dict(self): + return self._d + + ctx = MagicMock() + ctx.conversation_history = [FakeMsg(m) for m in messages] + return ctx + + def test_tool_messages_excluded(self): + bridge, _ = _make_bridge() + bridge.set_context( + self._make_ctx_with_history( + [ + {"role": "user", "content": "What time is it?"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + {"function": {"name": "get_time", "arguments": "{}"}} + ], + }, + { + "role": "tool", + "content": "success=True result={'time': '12:00'}", + }, + { + "role": "assistant", + "content": "It is 12:00.", + }, + ] + ) + ) + history = bridge._build_conversation_history() + roles = [m["role"] for m in history] + assert "tool" not in roles + assert roles == ["user", "assistant", "assistant"] + + def test_system_messages_excluded(self): + bridge, _ = _make_bridge() + bridge.set_context( + self._make_ctx_with_history( + [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi there!"}, + ] + ) + ) + history = bridge._build_conversation_history() + roles = [m["role"] for m in history] + assert "system" not in roles + assert roles == ["user", "assistant"] + + def test_empty_history_returns_none(self): + bridge, _ = _make_bridge() + bridge.set_context(self._make_ctx_with_history([])) + assert bridge._build_conversation_history() is None + + def test_only_tool_messages_returns_none(self): + bridge, _ = _make_bridge() + bridge.set_context( + self._make_ctx_with_history( + [ + {"role": "tool", "content": "result data"}, + {"role": "tool", "content": "more result data"}, + ] + ) + ) + assert bridge._build_conversation_history() is None + + +# --------------------------------------------------------------------------- +# _build_activity_history +# --------------------------------------------------------------------------- + + +class TestBuildActivityHistory: + """Verify activity stream replay from session history.""" + + def _make_ctx_with_history(self, messages): + class FakeMsg: + def __init__(self, d): + self._d = d + + def to_dict(self): + return self._d + + ctx = MagicMock() + ctx.conversation_history = [FakeMsg(m) for m in messages] + return ctx + + def test_pairs_tool_calls_with_results(self): + bridge, _ = _make_bridge() + bridge.set_context( + self._make_ctx_with_history( + [ + {"role": "user", "content": "What time is it?"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "function": { + "name": "get_time", + "arguments": '{"tz": "UTC"}', + }, + } + ], + }, + { + "role": "tool", + "content": "12:00 UTC", + "tool_call_id": "call_1", + }, + {"role": "assistant", "content": "It is 12:00 UTC."}, + ] + ) + ) + events = bridge._build_activity_history() + assert events is not None + + # Should have: 1 CONVERSATION_MESSAGE (for tool_calls) + 1 TOOL_RESULT + msg_events = [e for e in events if e["type"] == "CONVERSATION_MESSAGE"] + tool_events = [e for e in events if e["type"] == "TOOL_RESULT"] + + assert len(msg_events) == 1 + assert msg_events[0]["payload"]["tool_calls"] is not None + assert len(tool_events) == 1 + assert tool_events[0]["payload"]["tool_name"] == "get_time" + assert tool_events[0]["payload"]["result"] == "12:00 UTC" + assert tool_events[0]["payload"]["arguments"] == {"tz": "UTC"} + + def test_empty_history_returns_none(self): + bridge, _ = _make_bridge() + bridge.set_context(self._make_ctx_with_history([])) + assert bridge._build_activity_history() is None + + def test_no_tool_calls_returns_none(self): + bridge, _ = _make_bridge() + bridge.set_context( + self._make_ctx_with_history( + [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi there!"}, + ] + ) + ) + assert bridge._build_activity_history() is None + + def test_reasoning_included(self): + bridge, _ = _make_bridge() + bridge.set_context( + self._make_ctx_with_history( + [ + { + "role": "assistant", + "content": "Answer", + "reasoning_content": "Let me think...", + }, + ] + ) + ) + events = bridge._build_activity_history() + assert events is not None + assert len(events) == 1 + assert events[0]["type"] == "CONVERSATION_MESSAGE" + assert events[0]["payload"]["reasoning"] == "Let me think..." + + def test_multiple_tool_calls_in_one_message(self): + bridge, _ = _make_bridge() + bridge.set_context( + self._make_ctx_with_history( + [ + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "c1", + "function": { + "name": "tool_a", + "arguments": "{}", + }, + }, + { + "id": "c2", + "function": { + "name": "tool_b", + "arguments": '{"x": 1}', + }, + }, + ], + }, + {"role": "tool", "content": "result_a", "tool_call_id": "c1"}, + {"role": "tool", "content": "result_b", "tool_call_id": "c2"}, + ] + ) + ) + events = bridge._build_activity_history() + tool_events = [e for e in events if e["type"] == "TOOL_RESULT"] + assert len(tool_events) == 2 + names = {e["payload"]["tool_name"] for e in tool_events} + assert names == {"tool_a", "tool_b"} diff --git a/tests/dashboard/test_integration.py b/tests/dashboard/test_integration.py index 164f3e59..bb531a89 100644 --- a/tests/dashboard/test_integration.py +++ b/tests/dashboard/test_integration.py @@ -165,7 +165,7 @@ async def test_bridge_sends_view_registry_to_new_client(self, live_server): msg = json.loads(raw) assert msg["type"] == "VIEW_REGISTRY" - assert msg["views"][0]["id"] == "stats:main" + assert msg["payload"]["views"][0]["id"] == "stats:main" # --------------------------------------------------------------------------- @@ -309,4 +309,4 @@ async def test_view_discovered_from_tool_result_meta_ui(self, live_server): assert "VIEW_REGISTRY" in types registry_msg = next(m for m in msgs if m["type"] == "VIEW_REGISTRY") - assert any(v["id"] == "stats:main" for v in registry_msg["views"]) + assert any(v["id"] == "stats:main" for v in registry_msg["payload"]["views"]) From 1c8e3aa9f3eee2a965295f1a62c399e29c311378 Mon Sep 17 00:00:00 2001 From: chrishayuk Date: Sat, 28 Feb 2026 20:00:22 +0000 Subject: [PATCH 2/7] adding agent capabiltiies --- server_config.json | 3 +- src/mcp_cli/agents/__init__.py | 7 + src/mcp_cli/agents/config.py | 27 + src/mcp_cli/agents/group_store.py | 155 +++++ src/mcp_cli/agents/headless_ui.py | 88 +++ src/mcp_cli/agents/loop.py | 113 ++++ src/mcp_cli/agents/manager.py | 323 +++++++++++ src/mcp_cli/agents/tools.py | 287 ++++++++++ src/mcp_cli/chat/agent_tool_state.py | 41 ++ src/mcp_cli/chat/chat_context.py | 11 +- src/mcp_cli/chat/chat_handler.py | 81 ++- src/mcp_cli/chat/conversation.py | 27 +- src/mcp_cli/chat/session_store.py | 46 +- src/mcp_cli/chat/streaming_handler.py | 51 +- src/mcp_cli/chat/tool_processor.py | 51 +- src/mcp_cli/chat/ui_manager.py | 2 +- src/mcp_cli/commands/sessions/new.py | 13 +- src/mcp_cli/commands/sessions/sessions.py | 6 +- src/mcp_cli/config/cli_options.py | 15 +- src/mcp_cli/config/defaults.py | 8 + src/mcp_cli/dashboard/__init__.py | 3 +- src/mcp_cli/dashboard/bridge.py | 134 +++-- src/mcp_cli/dashboard/config.py | 8 + src/mcp_cli/dashboard/launcher.py | 8 +- src/mcp_cli/dashboard/router.py | 319 +++++++++++ src/mcp_cli/dashboard/server.py | 43 +- src/mcp_cli/dashboard/static/shell.html | 175 +++++- .../static/views/activity-stream.html | 70 ++- .../static/views/agent-overview.html | 284 ++++++++++ .../static/views/agent-terminal.html | 5 + src/mcp_cli/main.py | 12 + src/mcp_cli/tools/manager.py | 4 +- tests/agents/__init__.py | 0 tests/agents/test_config.py | 90 +++ tests/agents/test_group_store.py | 171 ++++++ tests/agents/test_loop.py | 254 +++++++++ tests/agents/test_manager.py | 323 +++++++++++ tests/agents/test_tools.py | 166 ++++++ tests/chat/test_agent_tool_state.py | 76 +++ tests/chat/test_chat_context.py | 67 ++- tests/chat/test_chat_handler_coverage.py | 179 ++++++ tests/chat/test_session_store.py | 65 ++- tests/chat/test_streaming_handler.py | 16 +- tests/chat/test_tool_processor_extended.py | 30 +- tests/chat/test_ui_manager_coverage.py | 6 +- tests/config/test_cli_options.py | 13 +- tests/dashboard/test_bridge_extended.py | 141 ++++- tests/dashboard/test_launcher.py | 27 +- tests/dashboard/test_router.py | 535 ++++++++++++++++++ tests/dashboard/test_server.py | 67 +++ 50 files changed, 4486 insertions(+), 160 deletions(-) create mode 100644 src/mcp_cli/agents/__init__.py create mode 100644 src/mcp_cli/agents/config.py create mode 100644 src/mcp_cli/agents/group_store.py create mode 100644 src/mcp_cli/agents/headless_ui.py create mode 100644 src/mcp_cli/agents/loop.py create mode 100644 src/mcp_cli/agents/manager.py create mode 100644 src/mcp_cli/agents/tools.py create mode 100644 src/mcp_cli/chat/agent_tool_state.py create mode 100644 src/mcp_cli/dashboard/router.py create mode 100644 src/mcp_cli/dashboard/static/views/agent-overview.html create mode 100644 tests/agents/__init__.py create mode 100644 tests/agents/test_config.py create mode 100644 tests/agents/test_group_store.py create mode 100644 tests/agents/test_loop.py create mode 100644 tests/agents/test_manager.py create mode 100644 tests/agents/test_tools.py create mode 100644 tests/chat/test_agent_tool_state.py create mode 100644 tests/dashboard/test_router.py diff --git a/server_config.json b/server_config.json index b7cec9b9..cc46273a 100644 --- a/server_config.json +++ b/server_config.json @@ -72,8 +72,7 @@ "args": ["--directory", "/Users/christopherhay/chris-source/chuk-ai/mcp-servers/chuk-mcp-stac", "run", "chuk-mcp-stac", "stdio"] }, "dem": { - "command": "uv", - "args": ["--directory", "/Users/christopherhay/chris-source/chuk-ai/mcp-servers/chuk-mcp-dem", "run", "chuk-mcp-dem", "stdio"] + "url": "https://dem.chukai.io/mcp" }, "maritime": { "url": "https://maritime-archives.chukai.io/mcp" diff --git a/src/mcp_cli/agents/__init__.py b/src/mcp_cli/agents/__init__.py new file mode 100644 index 00000000..6a642147 --- /dev/null +++ b/src/mcp_cli/agents/__init__.py @@ -0,0 +1,7 @@ +# mcp_cli/agents — Multi-agent orchestration +"""Agent orchestration: config, lifecycle management, and supervisor tools.""" + +from mcp_cli.agents.config import AgentConfig +from mcp_cli.agents.manager import AgentManager + +__all__ = ["AgentConfig", "AgentManager"] diff --git a/src/mcp_cli/agents/config.py b/src/mcp_cli/agents/config.py new file mode 100644 index 00000000..3c9c1f80 --- /dev/null +++ b/src/mcp_cli/agents/config.py @@ -0,0 +1,27 @@ +# mcp_cli/agents/config.py +"""Agent configuration for per-agent tool/server restrictions.""" + +from __future__ import annotations + +from pydantic import BaseModel + + +class AgentConfig(BaseModel): + """Configuration for a spawned agent. + + Controls identity, model selection, tool access, and parent relationship. + """ + + agent_id: str + name: str = "" + role: str = "" + model: str | None = None + provider: str | None = None + system_prompt: str | None = None + allowed_tools: list[str] | None = None # None = all tools + denied_tools: list[str] | None = None # explicit blocklist + allowed_servers: list[str] | None = None # None = all servers + tool_timeout_override: float | None = None + auto_approve_tools: list[str] | None = None # skip confirmation + parent_agent_id: str | None = None + initial_prompt: str = "" diff --git a/src/mcp_cli/agents/group_store.py b/src/mcp_cli/agents/group_store.py new file mode 100644 index 00000000..8abcec37 --- /dev/null +++ b/src/mcp_cli/agents/group_store.py @@ -0,0 +1,155 @@ +# mcp_cli/agents/group_store.py +"""Save and restore multi-agent groups. + +A *group* is a snapshot of all agents' configurations and their sessions, +stored on disk so the entire multi-agent setup can be resumed later. + +Layout:: + + ~/.mcp-cli/groups/{group_id}/ + group.json # AgentDescriptors + relationships + {agent_id}/session.json # Each agent's session state +""" + +from __future__ import annotations + +import json +import logging +import time +from pathlib import Path +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from mcp_cli.agents.manager import AgentManager + +logger = logging.getLogger(__name__) + +_DEFAULT_GROUPS_DIR = Path.home() / ".mcp-cli" / "groups" + + +async def save_group( + agent_manager: AgentManager, + description: str = "", + group_dir: Path | None = None, +) -> Path: + """Save all agents' configs and sessions to disk. + + Parameters + ---------- + agent_manager: + The AgentManager whose agents to save. + description: + Human-readable description for the group snapshot. + group_dir: + Override directory. Default: ``~/.mcp-cli/groups/{group_id}/`` + + Returns + ------- + Path + The directory where the group was saved. + """ + group_id = f"group-{int(time.time())}" + base = group_dir or (_DEFAULT_GROUPS_DIR / group_id) + base.mkdir(parents=True, exist_ok=True) + + agents_data: list[dict[str, Any]] = [] + + for status in agent_manager.list_agents(): + agent_id = status["agent_id"] + snapshot = agent_manager.get_agent_snapshot(agent_id) + if snapshot is None: + continue + + # Save agent config + config_dict = snapshot["config"].model_dump() + agents_data.append( + { + **config_dict, + "status": status.get("status", "unknown"), + } + ) + + # Save session if available + ctx = snapshot["context"] + agent_dir = base / agent_id + agent_dir.mkdir(parents=True, exist_ok=True) + try: + history = getattr(ctx, "conversation_history", []) + session_data = { + "agent_id": agent_id, + "session_id": getattr(ctx, "session_id", ""), + "messages": [ + m.to_dict() if hasattr(m, "to_dict") else dict(m) for m in history + ], + } + (agent_dir / "session.json").write_text( + json.dumps(session_data, indent=2, default=str) + ) + except Exception as exc: + logger.warning("Failed to save session for %s: %s", agent_id, exc) + + # Write group manifest + group_manifest = { + "group_id": group_id, + "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "description": description, + "agents": agents_data, + } + (base / "group.json").write_text(json.dumps(group_manifest, indent=2, default=str)) + + logger.info("Group saved to %s (%d agents)", base, len(agents_data)) + return base + + +def load_group_manifest(group_path: Path) -> dict[str, Any]: + """Load a group manifest from disk. + + Parameters + ---------- + group_path: + Path to the group directory containing ``group.json``. + + Returns + ------- + dict + The parsed group manifest. + """ + manifest_file = group_path / "group.json" + if not manifest_file.exists(): + raise FileNotFoundError(f"No group.json in {group_path}") + result: dict[str, Any] = json.loads(manifest_file.read_text()) + return result + + +def list_groups(groups_dir: Path | None = None) -> list[dict[str, Any]]: + """List all saved groups. + + Returns + ------- + list[dict] + List of ``{group_id, description, created_at, agent_count, path}`` + """ + base = groups_dir or _DEFAULT_GROUPS_DIR + if not base.exists(): + return [] + + results = [] + for d in sorted(base.iterdir()): + manifest_file = d / "group.json" + if not manifest_file.exists(): + continue + try: + manifest = json.loads(manifest_file.read_text()) + results.append( + { + "group_id": manifest.get("group_id", d.name), + "description": manifest.get("description", ""), + "created_at": manifest.get("created_at", ""), + "agent_count": len(manifest.get("agents", [])), + "path": str(d), + } + ) + except Exception as exc: + logger.debug("Skipping invalid group %s: %s", d, exc) + + return results diff --git a/src/mcp_cli/agents/headless_ui.py b/src/mcp_cli/agents/headless_ui.py new file mode 100644 index 00000000..f465e228 --- /dev/null +++ b/src/mcp_cli/agents/headless_ui.py @@ -0,0 +1,88 @@ +# mcp_cli/agents/headless_ui.py +"""Headless UI manager for spawned agents. + +Provides the same interface as ChatUIManager but with no terminal output. +All display operations are logged at DEBUG level instead. +""" + +from __future__ import annotations + +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +class HeadlessUIManager: + """UIManager for headless agents — no terminal output, logs only. + + Implements the subset of ChatUIManager used by ConversationProcessor + and ToolProcessor so spawned agents can run without a terminal. + """ + + def __init__(self, agent_id: str = "default") -> None: + self.agent_id = agent_id + self.verbose_mode = False + self.tool_calls: list[dict[str, Any]] = [] + self.tool_times: list[float] = [] + self.tool_start_time: float | None = None + self.current_tool_start_time: float | None = None + self.streaming_handler: Any = None + self.tools_running = False + self.display: Any = None # no display manager + self.console: Any = None # no rich console + + # -- Streaming ---------------------------------------------------------- + + @property + def is_streaming_response(self) -> bool: + return self.streaming_handler is not None + + async def start_streaming_response(self) -> None: + logger.debug("[%s] streaming response started", self.agent_id) + + async def stop_streaming_response(self) -> None: + logger.debug("[%s] streaming response stopped", self.agent_id) + self.streaming_handler = None + + # -- Tool display ------------------------------------------------------- + + def print_tool_call(self, tool_name: str, arguments: Any) -> None: + logger.debug("[%s] tool call: %s(%s)", self.agent_id, tool_name, arguments) + + async def start_tool_execution(self, tool_name: str, arguments: Any) -> None: + logger.debug("[%s] tool start: %s", self.agent_id, tool_name) + + async def finish_tool_execution( + self, result: str = "", success: bool = True + ) -> None: + logger.debug("[%s] tool finish: success=%s", self.agent_id, success) + + async def finish_tool_calls(self) -> None: + logger.debug("[%s] all tool calls finished", self.agent_id) + + # -- Confirmation ------------------------------------------------------- + + async def do_confirm_tool_execution( + self, + tool_name: str, + arguments: dict[str, Any], + server_name: str = "", + ) -> bool: + """Headless agents auto-approve all tool executions.""" + return True + + # -- Message display ---------------------------------------------------- + + async def print_assistant_message(self, content: str) -> None: + logger.debug("[%s] assistant: %.200s", self.agent_id, content) + + # -- Input (not used by headless agents) -------------------------------- + + async def get_user_input(self) -> str | None: + return None + + # -- Cleanup ------------------------------------------------------------ + + async def cleanup(self) -> None: + pass diff --git a/src/mcp_cli/agents/loop.py b/src/mcp_cli/agents/loop.py new file mode 100644 index 00000000..e4a43823 --- /dev/null +++ b/src/mcp_cli/agents/loop.py @@ -0,0 +1,113 @@ +# mcp_cli/agents/loop.py +"""Headless agent loop for spawned agents. + +Runs the ConversationProcessor in a background task, reading prompts from +an asyncio.Queue instead of stdin. No terminal I/O, no signal handlers. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +async def run_agent_loop( + context: Any, + ui_manager: Any, + input_queue: asyncio.Queue, + done_event: asyncio.Event, + max_turns: int = 50, +) -> str: + """Run a headless chat loop for a spawned agent. + + Similar to ``_run_enhanced_chat_loop`` in ``chat_handler.py`` but: + + * No terminal I/O — reads prompts from *input_queue*. + * Uses ``HeadlessUIManager`` (no rich/curses). + * Reports completion via *done_event*. + * Returns a summary of the final assistant response. + + Parameters + ---------- + context: + ChatContext for this agent. + ui_manager: + HeadlessUIManager instance. + input_queue: + ``asyncio.Queue`` that receives user/supervisor prompts. + done_event: + Set when the loop finishes. + max_turns: + Maximum conversation turns per prompt. + + Returns + ------- + str + Summary of the agent's last response, or error description. + """ + from mcp_cli.chat.conversation import ConversationProcessor + + convo = ConversationProcessor(context, ui_manager) + last_response = "" + + # Wire dashboard bridge if present + if bridge := getattr(context, "dashboard_bridge", None): + bridge.set_input_queue(input_queue) + + try: + while not context.exit_requested: + try: + # Wait for a prompt (with timeout so we can check exit) + try: + user_msg = await asyncio.wait_for(input_queue.get(), timeout=1.0) + except asyncio.TimeoutError: + continue + + if user_msg is None: + continue + + # Exit signals + msg_lower = str(user_msg).lower().strip() + if msg_lower in ("exit", "quit", "__stop__"): + break + + # Skip empty + if not str(user_msg).strip(): + continue + + # Add to conversation and process + await context.add_user_message(str(user_msg)) + + # Broadcast to dashboard if wired + if dash := getattr(context, "dashboard_bridge", None): + try: + await dash.on_message("user", str(user_msg)) + except Exception as exc: + logger.debug("Dashboard on_message error: %s", exc) + + await convo.process_conversation(max_turns=max_turns) + + # Capture last assistant response for summary + for msg in reversed(context.conversation_history): + if hasattr(msg, "role") and msg.role == "assistant": + last_response = getattr(msg, "content", "") or "" + break + elif isinstance(msg, dict) and msg.get("role") == "assistant": + last_response = msg.get("content", "") or "" + break + + except asyncio.CancelledError: + logger.info("Agent loop cancelled: %s", context.agent_id) + break + except Exception as exc: + logger.exception("Error in agent loop: %s", exc) + last_response = f"Error: {exc}" + break + + finally: + done_event.set() + + return last_response[:500] if last_response else "Agent completed." diff --git a/src/mcp_cli/agents/manager.py b/src/mcp_cli/agents/manager.py new file mode 100644 index 00000000..6b151012 --- /dev/null +++ b/src/mcp_cli/agents/manager.py @@ -0,0 +1,323 @@ +# mcp_cli/agents/manager.py +"""AgentManager — orchestration hub for multi-agent scenarios. + +Manages agent lifecycle (spawn, stop, wait), inter-agent messaging, +and shared artifacts. +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +from mcp_cli.agents.config import AgentConfig +from mcp_cli.chat.agent_tool_state import remove_agent_tool_state +from mcp_cli.dashboard.router import AgentDescriptor + +if TYPE_CHECKING: + from mcp_cli.dashboard.router import AgentRouter + from mcp_cli.tools.manager import ToolManager + +logger = logging.getLogger(__name__) + +MAX_AGENTS = 10 + + +@dataclass +class _AgentHandle: + """Internal bookkeeping for a spawned agent.""" + + agent_id: str + config: AgentConfig + context: Any # ChatContext + bridge: Any # DashboardBridge | None + task: asyncio.Task + input_queue: asyncio.Queue + done_event: asyncio.Event + result_summary: str = "" + created_at: float = field(default_factory=time.time) + + +class AgentManager: + """Manages spawning, stopping, and communicating with agents.""" + + def __init__( + self, + tool_manager: ToolManager, + router: AgentRouter, + model_manager: Any = None, + ) -> None: + self._tool_manager = tool_manager + self._router = router + self._model_manager = model_manager + self._agents: dict[str, _AgentHandle] = {} + self._artifacts: dict[str, dict[str, Any]] = {} # id → {agent_id, content, ...} + self._message_queues: dict[str, asyncio.Queue] = {} + + # ------------------------------------------------------------------ # + # Lifecycle # + # ------------------------------------------------------------------ # + + async def spawn_agent(self, config: AgentConfig) -> str: + """Spawn a new agent from *config*. Returns the agent_id.""" + if len(self._agents) >= MAX_AGENTS: + raise RuntimeError(f"Maximum of {MAX_AGENTS} concurrent agents reached.") + if config.agent_id in self._agents: + raise ValueError(f"Agent {config.agent_id!r} already exists.") + + agent_id = config.agent_id + registered = False # track router registration for rollback + + # Lazy imports to avoid circular deps + from mcp_cli.agents.headless_ui import HeadlessUIManager + from mcp_cli.agents.loop import run_agent_loop + from mcp_cli.chat.chat_context import ChatContext + from mcp_cli.dashboard.bridge import DashboardBridge + + try: + # Create agent's ChatContext + ctx = ChatContext.create( + tool_manager=self._tool_manager, + provider=config.provider, + model=config.model, + model_manager=self._model_manager, + agent_id=agent_id, + ) + if not await ctx.initialize(): + raise RuntimeError(f"Failed to initialize context for {agent_id}") + + # Apply system prompt override + if config.system_prompt: + ctx._system_prompt = config.system_prompt + ctx._system_prompt_dirty = True + + # Apply tool filtering + if config.allowed_tools is not None: + allowed = set(config.allowed_tools) + ctx.openai_tools = [ + t + for t in ctx.openai_tools + if t.get("function", {}).get("name", "") in allowed + ] + if config.denied_tools is not None: + denied = set(config.denied_tools) + ctx.openai_tools = [ + t + for t in ctx.openai_tools + if t.get("function", {}).get("name", "") not in denied + ] + + # Create bridge and register with router + bridge = DashboardBridge(self._router, agent_id=agent_id) + bridge.set_context(ctx) + ctx.dashboard_bridge = bridge + + descriptor = AgentDescriptor( + agent_id=agent_id, + name=config.name or agent_id, + role=config.role, + model=config.model or "", + parent_agent_id=config.parent_agent_id, + tool_count=len(ctx.openai_tools), + ) + self._router.register_agent(agent_id, bridge, descriptor=descriptor) + registered = True + + # Create headless UI and input queue + ui = HeadlessUIManager(agent_id=agent_id) + input_queue: asyncio.Queue = asyncio.Queue() + done_event = asyncio.Event() + + # Message queue for inter-agent messaging + self._message_queues[agent_id] = asyncio.Queue() + + # Launch the agent loop + task = asyncio.create_task( + run_agent_loop(ctx, ui, input_queue, done_event), + name=f"agent-loop-{agent_id}", + ) + + handle = _AgentHandle( + agent_id=agent_id, + config=config, + context=ctx, + bridge=bridge, + task=task, + input_queue=input_queue, + done_event=done_event, + ) + self._agents[agent_id] = handle + + # Inject initial prompt + if config.initial_prompt: + await input_queue.put(config.initial_prompt) + + logger.info("Spawned agent %s (role=%s)", agent_id, config.role) + return agent_id + + except BaseException: + # Rollback partial state so the system stays consistent + if registered: + self._router.unregister_agent(agent_id) + self._message_queues.pop(agent_id, None) + remove_agent_tool_state(agent_id) + logger.error("Failed to spawn agent %s — rolled back", agent_id) + raise + + async def stop_agent(self, agent_id: str) -> bool: + """Stop an agent. Returns True if it existed.""" + handle = self._agents.pop(agent_id, None) + if handle is None: + return False + + # Cancel the task + handle.task.cancel() + try: + await asyncio.wait_for(asyncio.shield(handle.task), timeout=5.0) + except (asyncio.CancelledError, asyncio.TimeoutError): + pass + + # Update status and unregister + await self._router.update_agent_status(agent_id, "completed") + self._router.unregister_agent(agent_id) + + # Cleanup tool state + remove_agent_tool_state(agent_id) + self._message_queues.pop(agent_id, None) + + logger.info("Stopped agent %s", agent_id) + return True + + async def wait_agent(self, agent_id: str, timeout: float = 300) -> dict[str, Any]: + """Wait for an agent to finish. Returns status dict.""" + handle = self._agents.get(agent_id) + if handle is None: + return {"error": f"Unknown agent: {agent_id}"} + + try: + await asyncio.wait_for(handle.done_event.wait(), timeout=timeout) + except asyncio.TimeoutError: + return { + "agent_id": agent_id, + "status": "timeout", + "summary": f"Agent did not finish within {timeout}s", + } + + # Capture summary + try: + handle.result_summary = handle.task.result() + except (asyncio.CancelledError, Exception) as exc: + handle.result_summary = f"Error: {exc}" + + return { + "agent_id": agent_id, + "status": "completed", + "summary": handle.result_summary, + } + + # ------------------------------------------------------------------ # + # Messaging # + # ------------------------------------------------------------------ # + + async def send_message(self, from_id: str, to_id: str, content: str) -> bool: + """Send a message from one agent to another. + + The message is injected into the target agent's input queue + as a system-annotated prompt. + """ + handle = self._agents.get(to_id) + if handle is None: + return False + + annotated = f"[Message from {from_id}]: {content}" + await handle.input_queue.put(annotated) + logger.debug("Message from %s → %s: %.100s", from_id, to_id, content) + return True + + async def get_messages(self, agent_id: str) -> list[dict[str, Any]]: + """Drain pending inter-agent messages for *agent_id*. + + Returns list of ``{from_agent, content}`` dicts. + """ + queue = self._message_queues.get(agent_id) + if queue is None: + return [] + messages = [] + while not queue.empty(): + try: + messages.append(queue.get_nowait()) + except asyncio.QueueEmpty: + break + return messages + + # ------------------------------------------------------------------ # + # Artifacts # + # ------------------------------------------------------------------ # + + def publish_artifact(self, agent_id: str, artifact_id: str, content: Any) -> None: + """Publish an artifact to the shared store.""" + self._artifacts[artifact_id] = { + "agent_id": agent_id, + "content": content, + "created_at": time.time(), + } + logger.debug("Artifact published: %s by %s", artifact_id, agent_id) + + def get_artifact(self, artifact_id: str) -> Any | None: + """Retrieve an artifact by ID, or None.""" + entry = self._artifacts.get(artifact_id) + return entry["content"] if entry else None + + def list_artifacts(self) -> list[dict[str, Any]]: + """List all published artifacts.""" + return [ + {"artifact_id": k, "agent_id": v["agent_id"]} + for k, v in self._artifacts.items() + ] + + # ------------------------------------------------------------------ # + # Status # + # ------------------------------------------------------------------ # + + def get_agent_status(self, agent_id: str) -> dict[str, Any] | None: + """Return status dict for a single agent, or None.""" + handle = self._agents.get(agent_id) + if handle is None: + return None + return { + "agent_id": handle.agent_id, + "name": handle.config.name, + "role": handle.config.role, + "model": handle.config.model, + "provider": handle.config.provider, + "status": "active" if not handle.done_event.is_set() else "completed", + "parent_agent_id": handle.config.parent_agent_id, + } + + def list_agents(self) -> list[dict[str, Any]]: + """Return status dicts for all managed agents.""" + return [self.get_agent_status(aid) for aid in self._agents] # type: ignore[misc] + + def get_agent_snapshot(self, agent_id: str) -> dict[str, Any] | None: + """Return config and context for an agent (used by group_store). + + Returns ``{config: AgentConfig, context: ChatContext}`` or None. + """ + handle = self._agents.get(agent_id) + if handle is None: + return None + return {"config": handle.config, "context": handle.context} + + # ------------------------------------------------------------------ # + # Cleanup # + # ------------------------------------------------------------------ # + + async def stop_all(self) -> None: + """Stop all managed agents.""" + agent_ids = list(self._agents.keys()) + for agent_id in agent_ids: + await self.stop_agent(agent_id) + logger.info("All agents stopped.") diff --git a/src/mcp_cli/agents/tools.py b/src/mcp_cli/agents/tools.py new file mode 100644 index 00000000..b91f15e7 --- /dev/null +++ b/src/mcp_cli/agents/tools.py @@ -0,0 +1,287 @@ +# mcp_cli/agents/tools.py +"""Supervisor tool definitions and handler for agent orchestration. + +Provides internal tools that the LLM can call to spawn, stop, and +communicate with other agents. These tools are intercepted in +tool_processor.py before MCP routing (same pattern as plan tools). + +Tools: +- agent_spawn: Spawn a new agent with a role and initial prompt +- agent_stop: Stop a running agent +- agent_message: Send a message to another agent +- agent_wait: Wait for an agent to finish +- agent_status: Query an agent's current status +- agent_list: List all managed agents +""" + +from __future__ import annotations + +import json +import logging +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from mcp_cli.agents.manager import AgentManager + +logger = logging.getLogger(__name__) + +_AGENT_TOOL_NAMES = frozenset( + { + "agent_spawn", + "agent_stop", + "agent_message", + "agent_wait", + "agent_status", + "agent_list", + } +) + + +def get_agent_tools_as_dicts() -> list[dict[str, Any]]: + """Return OpenAI-format tool definitions for agent orchestration.""" + return [ + { + "type": "function", + "function": { + "name": "agent_spawn", + "description": ( + "Spawn a new agent to work on a sub-task in parallel. " + "The agent runs independently and can be monitored or stopped." + ), + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Human-readable name for the agent.", + }, + "role": { + "type": "string", + "description": ( + "Role description (e.g. 'researcher', 'coder')." + ), + }, + "model": { + "type": "string", + "description": "Model to use (optional, defaults to current).", + }, + "provider": { + "type": "string", + "description": "Provider to use (optional, defaults to current).", + }, + "initial_prompt": { + "type": "string", + "description": "The task/prompt to give the new agent.", + }, + }, + "required": ["name", "initial_prompt"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "agent_stop", + "description": "Stop a running agent by its agent_id.", + "parameters": { + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "description": "The ID of the agent to stop.", + }, + }, + "required": ["agent_id"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "agent_message", + "description": ( + "Send a message to another agent. The message is injected " + "into that agent's input as a prompt." + ), + "parameters": { + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "description": "The target agent's ID.", + }, + "content": { + "type": "string", + "description": "Message content to send.", + }, + }, + "required": ["agent_id", "content"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "agent_wait", + "description": ( + "Wait for an agent to finish its task. Returns the agent's " + "completion summary." + ), + "parameters": { + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "description": "The agent to wait for.", + }, + "timeout": { + "type": "number", + "description": "Max seconds to wait (default: 300).", + }, + }, + "required": ["agent_id"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "agent_status", + "description": "Get the current status of a specific agent.", + "parameters": { + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "description": "The agent to query.", + }, + }, + "required": ["agent_id"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "agent_list", + "description": "List all currently managed agents and their statuses.", + "parameters": { + "type": "object", + "properties": {}, + }, + }, + }, + ] + + +async def handle_agent_tool( + tool_name: str, + arguments: dict[str, Any], + agent_manager: AgentManager, + caller_agent_id: str = "default", +) -> str: + """Execute an agent orchestration tool. + + Parameters + ---------- + tool_name: + One of the ``_AGENT_TOOL_NAMES``. + arguments: + Tool arguments from the LLM. + agent_manager: + The AgentManager instance. + caller_agent_id: + The agent_id of the caller (for message attribution). + + Returns + ------- + str + JSON string with the result. + """ + try: + if tool_name == "agent_spawn": + return await _handle_spawn(arguments, agent_manager, caller_agent_id) + if tool_name == "agent_stop": + return await _handle_stop(arguments, agent_manager) + if tool_name == "agent_message": + return await _handle_message(arguments, agent_manager, caller_agent_id) + if tool_name == "agent_wait": + return await _handle_wait(arguments, agent_manager) + if tool_name == "agent_status": + return _handle_status(arguments, agent_manager) + if tool_name == "agent_list": + return _handle_list(agent_manager) + return json.dumps({"error": f"Unknown agent tool: {tool_name}"}) + except Exception as exc: + logger.error("Agent tool %s failed: %s", tool_name, exc) + return json.dumps({"error": str(exc)}) + + +async def _handle_spawn( + arguments: dict[str, Any], + manager: AgentManager, + caller_id: str, +) -> str: + from mcp_cli.agents.config import AgentConfig + + name = arguments.get("name", "unnamed") + # Generate agent_id from name + agent_id = f"agent-{name.lower().replace(' ', '-')}" + + config = AgentConfig( + agent_id=agent_id, + name=name, + role=arguments.get("role", ""), + model=arguments.get("model"), + provider=arguments.get("provider"), + parent_agent_id=caller_id, + initial_prompt=arguments.get("initial_prompt", ""), + ) + + result_id = await manager.spawn_agent(config) + return json.dumps({"success": True, "agent_id": result_id, "name": name}) + + +async def _handle_stop(arguments: dict[str, Any], manager: AgentManager) -> str: + agent_id = arguments.get("agent_id", "") + if not agent_id: + return json.dumps({"error": "agent_id is required"}) + stopped = await manager.stop_agent(agent_id) + return json.dumps({"success": stopped, "agent_id": agent_id}) + + +async def _handle_message( + arguments: dict[str, Any], + manager: AgentManager, + caller_id: str, +) -> str: + agent_id = arguments.get("agent_id", "") + content = arguments.get("content", "") + if not agent_id or not content: + return json.dumps({"error": "agent_id and content are required"}) + sent = await manager.send_message(caller_id, agent_id, content) + return json.dumps({"success": sent, "to": agent_id}) + + +async def _handle_wait(arguments: dict[str, Any], manager: AgentManager) -> str: + agent_id = arguments.get("agent_id", "") + if not agent_id: + return json.dumps({"error": "agent_id is required"}) + timeout = float(arguments.get("timeout", 300)) + result = await manager.wait_agent(agent_id, timeout=timeout) + return json.dumps(result, default=str) + + +def _handle_status(arguments: dict[str, Any], manager: AgentManager) -> str: + agent_id = arguments.get("agent_id", "") + if not agent_id: + return json.dumps({"error": "agent_id is required"}) + status = manager.get_agent_status(agent_id) + if status is None: + return json.dumps({"error": f"Unknown agent: {agent_id}"}) + return json.dumps(status, default=str) + + +def _handle_list(manager: AgentManager) -> str: + agents = manager.list_agents() + return json.dumps({"agents": agents}, default=str) diff --git a/src/mcp_cli/chat/agent_tool_state.py b/src/mcp_cli/chat/agent_tool_state.py new file mode 100644 index 00000000..5170de09 --- /dev/null +++ b/src/mcp_cli/chat/agent_tool_state.py @@ -0,0 +1,41 @@ +"""Per-agent tool state isolation. + +Wraps the global get_tool_state() singleton from chuk_ai_session_manager +with a per-agent registry. The ``"default"`` agent_id delegates to the +global singleton; other agent_ids get independent ToolStateManager instances. +""" + +from __future__ import annotations + +import logging + +from chuk_ai_session_manager.guards import get_tool_state +from chuk_ai_session_manager.guards.manager import ToolStateManager + +logger = logging.getLogger(__name__) + +_registry: dict[str, ToolStateManager] = {} + + +def get_agent_tool_state(agent_id: str = "default") -> ToolStateManager: + """Return a ToolStateManager scoped to the given *agent_id*. + + * ``"default"`` → delegates to the upstream global singleton + * anything else → per-agent instance (created on first access) + """ + if agent_id == "default": + return get_tool_state() + if agent_id not in _registry: + _registry[agent_id] = ToolStateManager() + logger.debug("Created tool state for agent %s", agent_id) + return _registry[agent_id] + + +def remove_agent_tool_state(agent_id: str) -> None: + """Remove tool state for an agent (call when the agent is stopped).""" + _registry.pop(agent_id, None) + + +def _reset_registry() -> None: + """Clear the registry — for testing only.""" + _registry.clear() diff --git a/src/mcp_cli/chat/chat_context.py b/src/mcp_cli/chat/chat_context.py index 5f7738d5..394c16ed 100644 --- a/src/mcp_cli/chat/chat_context.py +++ b/src/mcp_cli/chat/chat_context.py @@ -63,6 +63,7 @@ def __init__( vm_budget: int = 128_000, health_interval: int = 0, enable_plan_tools: bool = False, + agent_id: str = "default", ): """ Create chat context with required managers. @@ -84,6 +85,7 @@ def __init__( self.tool_manager = tool_manager self.model_manager = model_manager self.session_id = session_id or self._generate_session_id() + self.agent_id = agent_id # Context management self._max_history_messages = max_history_messages @@ -125,7 +127,7 @@ def __init__( self.token_tracker = TokenTracker() # Session persistence - self._session_store = SessionStore() + self._session_store = SessionStore(agent_id=self.agent_id) self._auto_save_counter = 0 # ToolProcessor back-reference (set by ToolProcessor.__init__) @@ -134,6 +136,9 @@ def __init__( # Dashboard bridge (set by chat_handler when --dashboard is active, else None) self.dashboard_bridge: Any = None + # Agent manager (set by chat_handler when multi-agent enabled, else None) + self.agent_manager: Any = None + # Tool state (filled during initialization) self.tools: list[ToolInfo] = [] self.internal_tools: list[ToolInfo] = [] @@ -169,6 +174,7 @@ def create( vm_budget: int = 128_000, health_interval: int = 0, enable_plan_tools: bool = False, + agent_id: str = "default", ) -> "ChatContext": """ Factory method for convenient creation. @@ -223,6 +229,7 @@ def create( vm_budget=vm_budget, health_interval=health_interval, enable_plan_tools=enable_plan_tools, + agent_id=agent_id, ) # ── Properties ──────────────────────────────────────────────────────── @@ -838,6 +845,7 @@ def save_session(self) -> str | None: data = SessionData( metadata=SessionMetadata( session_id=self.session_id, + agent_id=self.agent_id, provider=self.provider, model=self.model, message_count=len(message_dicts), @@ -957,6 +965,7 @@ def to_dict(self) -> dict[str, Any]: "tool_to_server_map": self.tool_to_server_map, "tool_manager": self.tool_manager, "session_id": self.session_id, + "agent_id": self.agent_id, } def update_from_dict(self, context_dict: dict[str, Any]) -> None: diff --git a/src/mcp_cli/chat/chat_handler.py b/src/mcp_cli/chat/chat_handler.py index a531b841..74beb2ee 100644 --- a/src/mcp_cli/chat/chat_handler.py +++ b/src/mcp_cli/chat/chat_handler.py @@ -52,6 +52,8 @@ async def handle_chat_mode( dashboard: bool = False, no_browser: bool = False, dashboard_port: int = 0, + agent_id: str = "default", + multi_agent: bool = False, ) -> bool: """ Launch the interactive chat loop with streaming support. @@ -74,12 +76,18 @@ async def handle_chat_mode( dashboard: Launch browser dashboard alongside chat (requires websockets). no_browser: If True, print dashboard URL but do not open the browser. dashboard_port: Preferred dashboard port (0 = auto-select). + multi_agent: Enable multi-agent orchestration tools (implies dashboard). Returns: True if session ended normally, False on failure """ + # Multi-agent implies dashboard (needs the router for agent bridges) + if multi_agent: + dashboard = True + ui: ChatUIManager | None = None ctx = None + agent_manager = None try: # Initialize configuration manager @@ -115,6 +123,7 @@ def on_progress(msg: str) -> None: vm_budget=vm_budget, health_interval=health_interval, enable_plan_tools=enable_plan_tools, + agent_id=agent_id, ) if not await ctx.initialize(on_progress=on_progress): @@ -130,12 +139,15 @@ def on_progress(msg: str) -> None: from mcp_cli.dashboard.launcher import launch_dashboard from mcp_cli.dashboard.bridge import DashboardBridge - _dash_server, _dash_port = await launch_dashboard( + _dash_server, _dash_router, _dash_port = await launch_dashboard( dashboard_port, no_browser ) output.info(f"Dashboard: http://localhost:{_dash_port}") - ctx.dashboard_bridge = DashboardBridge(_dash_server) + ctx.dashboard_bridge = DashboardBridge( + _dash_router, agent_id=ctx.agent_id + ) ctx.dashboard_bridge.set_context(ctx) + _dash_router.register_agent(ctx.agent_id, ctx.dashboard_bridge) # Wire REQUEST_TOOL from browser → tool_manager, result back to browser _bridge_ref = ctx.dashboard_bridge @@ -153,6 +165,19 @@ async def _dashboard_execute_tool( ) ctx.dashboard_bridge.set_tool_call_callback(_dashboard_execute_tool) + + # Multi-agent: create AgentManager and set on context + if multi_agent: + from mcp_cli.agents.manager import AgentManager as _AM + + agent_manager = _AM( + tool_manager=tool_manager, + router=_dash_router, + model_manager=app_context.model_manager, + ) + ctx.agent_manager = agent_manager + logger.info("Multi-agent mode enabled (%s)", ctx.agent_id) + except ImportError: output.warning( "Dashboard requires 'websockets'. Install with: pip install mcp-cli[dashboard]" @@ -234,6 +259,13 @@ async def _dashboard_execute_tool( if ui: await _safe_cleanup(ui) + # Stop all managed agents before tearing down dashboard + if agent_manager is not None: + try: + await agent_manager.stop_all() + except Exception as exc: + logger.warning("Error stopping agents: %s", exc) + # Stop dashboard server if running if ctx is not None and ctx.dashboard_bridge is not None: try: @@ -321,10 +353,26 @@ async def handle_chat_mode_for_testing( _INTERRUPT = object() -async def _terminal_reader(ui: ChatUIManager, queue: asyncio.Queue) -> None: - """Background task: reads terminal input and puts it on the shared queue.""" +async def _terminal_reader( + ui: ChatUIManager, + queue: asyncio.Queue, + ready: asyncio.Event | None = None, +) -> None: + """Background task: reads terminal input and puts it on the shared queue. + + When *ready* is provided the reader waits for it before showing the prompt. + This prevents the prompt from being overwritten by streaming / tool output. + The reader clears the event immediately (one-shot) so it won't re-enter + ``get_user_input`` until the main loop explicitly re-sets the event. + """ while True: try: + # Wait until the main loop signals it's ready for new input. + # Clear immediately so we only get ONE prompt per signal. + if ready is not None: + await ready.wait() + ready.clear() + msg = await ui.get_user_input() await queue.put(msg) except EOFError: @@ -373,8 +421,15 @@ async def _run_enhanced_chat_loop( if bridge := getattr(ctx, "dashboard_bridge", None): bridge.set_input_queue(input_queue) + # Gate the prompt display: the reader waits for this event before showing + # the "💬 You:" prompt, preventing streaming / tool output from overwriting it. + prompt_ready = asyncio.Event() + prompt_ready.set() # Ready immediately for the first prompt + # Background task: reads terminal input and forwards to the queue. - reader_task = asyncio.create_task(_terminal_reader(ui, input_queue)) + reader_task = asyncio.create_task( + _terminal_reader(ui, input_queue, ready=prompt_ready) + ) try: while True: @@ -398,10 +453,12 @@ async def _run_enhanced_chat_loop( ui._interrupt_now() else: output.warning("\nInterrupted - type 'exit' to quit.") + prompt_ready.set() continue # Skip empty messages if not user_msg: + prompt_ready.set() continue # Handle plain exit commands (without slash) @@ -416,18 +473,22 @@ async def _run_enhanced_chat_loop( if ui.is_streaming_response: ui.interrupt_streaming() output.warning("Streaming response interrupted.") + prompt_ready.set() continue elif ui.tools_running: ui._interrupt_now() + prompt_ready.set() continue else: output.info("Nothing to interrupt.") + prompt_ready.set() continue handled = await ui.handle_command(user_msg) if ctx.exit_requested: break if handled: + prompt_ready.set() continue # Normal conversation turn with streaming support @@ -442,8 +503,12 @@ async def _run_enhanced_chat_loop( except Exception as _e: logger.debug("Dashboard on_message(user) error: %s", _e) - # Use the enhanced conversation processor that handles streaming - await convo.process_conversation(max_turns=max_turns) + # Process the conversation. The reader already cleared + # prompt_ready so no new prompt is shown during streaming. + try: + await convo.process_conversation(max_turns=max_turns) + finally: + prompt_ready.set() except (KeyboardInterrupt, asyncio.CancelledError): # Handle Ctrl+C gracefully @@ -462,6 +527,7 @@ async def _run_enhanced_chat_loop( output.warning("\nInterrupted - type 'exit' to quit.") # CRITICAL: Continue the loop instead of exiting logger.info("Continuing chat loop after interrupt...") + prompt_ready.set() continue except EOFError: output.panel("EOF detected - exiting chat.", style="red", title="Exit") @@ -469,6 +535,7 @@ async def _run_enhanced_chat_loop( except Exception as exc: logger.exception("Error processing message") output.error(f"Error processing message: {exc}") + prompt_ready.set() continue finally: reader_task.cancel() diff --git a/src/mcp_cli/chat/conversation.py b/src/mcp_cli/chat/conversation.py index f9dc82c4..2459e8af 100644 --- a/src/mcp_cli/chat/conversation.py +++ b/src/mcp_cli/chat/conversation.py @@ -24,7 +24,7 @@ from mcp_cli.chat.tool_processor import ToolProcessor from mcp_cli.chat.token_tracker import TokenTracker, TurnUsage from mcp_cli.config.defaults import DEFAULT_MAX_CONSECUTIVE_DUPLICATES -from chuk_ai_session_manager.guards import get_tool_state +from mcp_cli.chat.agent_tool_state import get_agent_tool_state logger = logging.getLogger(__name__) @@ -68,7 +68,7 @@ def __init__( # Store runtime_config for passing to streaming handler self.runtime_config = runtime_config # Tool state manager for caching and variable binding - self._tool_state = get_tool_state() + self._tool_state = get_agent_tool_state(getattr(context, "agent_id", "default")) # Counter for consecutive duplicate detections (for escalation) self._consecutive_duplicate_count = 0 self._max_consecutive_duplicates = DEFAULT_MAX_CONSECUTIVE_DUPLICATES @@ -891,6 +891,29 @@ async def _inject_internal_tools(self): except Exception as exc: logger.warning(f"Could not load plan tools: {exc}") + # Inject agent orchestration tools when agent_manager is set + if ( + getattr(self.context, "agent_manager", None) is not None + and "agent_spawn" not in existing + ): + try: + from mcp_cli.agents.tools import get_agent_tools_as_dicts + + agent_tools = get_agent_tools_as_dicts() + new_agent = [ + t + for t in agent_tools + if t.get("function", {}).get("name", "") not in existing + ] + if new_agent: + self.context.openai_tools.extend(new_agent) + existing.update( + t.get("function", {}).get("name", "") for t in new_agent + ) + logger.info(f"Injected {len(new_agent)} agent tools") + except Exception as exc: + logger.warning(f"Could not load agent tools: {exc}") + # Inject persistent memory scope tools store = getattr(self.context, "memory_store", None) if store and "memory_store_page" not in existing: diff --git a/src/mcp_cli/chat/session_store.py b/src/mcp_cli/chat/session_store.py index e2987a6c..23e2abac 100644 --- a/src/mcp_cli/chat/session_store.py +++ b/src/mcp_cli/chat/session_store.py @@ -13,7 +13,7 @@ from pydantic import BaseModel, Field -from mcp_cli.config.defaults import DEFAULT_SESSIONS_DIR +from mcp_cli.config.defaults import DEFAULT_AGENT_ID, DEFAULT_SESSIONS_DIR logger = logging.getLogger(__name__) @@ -22,6 +22,7 @@ class SessionMetadata(BaseModel): """Metadata for a saved session.""" session_id: str + agent_id: str = DEFAULT_AGENT_ID created_at: str = Field( default_factory=lambda: datetime.now(timezone.utc).isoformat() ) @@ -48,11 +49,19 @@ class SessionStore: Stores sessions as JSON files in a configurable directory. """ - def __init__(self, sessions_dir: Path | None = None): + def __init__( + self, + sessions_dir: Path | None = None, + agent_id: str = DEFAULT_AGENT_ID, + ): if sessions_dir is None: sessions_dir = Path(DEFAULT_SESSIONS_DIR).expanduser() - self.sessions_dir = sessions_dir + self.agent_id = agent_id + # Agent-namespaced subdirectory + self.sessions_dir = sessions_dir / agent_id self.sessions_dir.mkdir(parents=True, exist_ok=True) + # Keep reference to root for backward-compat migration + self._root_dir = sessions_dir def _session_path(self, session_id: str) -> Path: """Get the file path for a session.""" @@ -80,6 +89,9 @@ def save(self, data: SessionData) -> Path: def load(self, session_id: str) -> SessionData | None: """Load session data from disk. + If the file isn't found in the agent-namespaced directory, checks the + flat root directory for backward compatibility and auto-migrates. + Args: session_id: Session ID to load @@ -88,8 +100,12 @@ def load(self, session_id: str) -> SessionData | None: """ path = self._session_path(session_id) if not path.exists(): - logger.warning(f"Session not found: {session_id}") - return None + # Backward-compat: check flat root directory and migrate + migrated = self._migrate_from_root(session_id) + if migrated is None: + logger.warning(f"Session not found: {session_id}") + return None + path = migrated try: raw = path.read_text(encoding="utf-8") @@ -99,6 +115,26 @@ def load(self, session_id: str) -> SessionData | None: logger.error(f"Failed to load session {session_id}: {e}") return None + def _migrate_from_root(self, session_id: str) -> Path | None: + """Check flat root dir for a legacy session file and migrate it. + + Returns the new path if migration succeeded, None otherwise. + """ + safe_id = session_id.replace("/", "_").replace("\\", "_").replace("..", "_") + legacy_path = self._root_dir / f"{safe_id}.json" + if not legacy_path.exists() or not legacy_path.is_file(): + return None + dest = self._session_path(session_id) + try: + import shutil + + shutil.move(str(legacy_path), str(dest)) + logger.info(f"Migrated session {session_id} from flat dir to {dest}") + return dest + except Exception as e: + logger.warning(f"Failed to migrate session {session_id}: {e}") + return None + def list_sessions(self) -> list[SessionMetadata]: """List all saved sessions. diff --git a/src/mcp_cli/chat/streaming_handler.py b/src/mcp_cli/chat/streaming_handler.py index aa746531..2bef143b 100644 --- a/src/mcp_cli/chat/streaming_handler.py +++ b/src/mcp_cli/chat/streaming_handler.py @@ -438,22 +438,19 @@ async def stream_chunks(): f"(first_chunk={not first_chunk_received}, " f"after_tools={after_tool_calls})" ) - # Display user-friendly error message - from chuk_term.ui import output - if not first_chunk_received and after_tool_calls: - output.error( - f"\n⏱️ Streaming timeout after {effective_timeout:.0f}s waiting for first response after tool calls.\n" - "The model may need more time to process tool results.\n" - f"You can increase this with: MCP_STREAMING_FIRST_CHUNK_TIMEOUT={effective_timeout * 2:.0f}\n" - f"Or set in config: timeouts.streamingFirstChunk = {effective_timeout * 2:.0f}" + logger.warning( + "Streaming timeout after %.0fs waiting for first response after tool calls. " + "Increase with MCP_STREAMING_FIRST_CHUNK_TIMEOUT=%.0f", + effective_timeout, + effective_timeout * 2, ) else: - output.error( - f"\n⏱️ Streaming timeout after {effective_timeout:.0f}s waiting for response.\n" - "The model may be taking longer than expected to respond.\n" - f"You can increase this timeout with: --tool-timeout {effective_timeout * 2:.0f}\n" - f"Or set in config file: timeouts.streamingChunkTimeout = {effective_timeout * 2:.0f}" + logger.warning( + "Streaming timeout after %.0fs waiting for response. " + "Increase with --tool-timeout %.0f", + effective_timeout, + effective_timeout * 2, ) break @@ -461,15 +458,12 @@ async def stream_chunks(): try: await asyncio.wait_for(stream_chunks(), timeout=global_timeout) except asyncio.TimeoutError: - logger.error(f"Global streaming timeout after {global_timeout}s") - # Display user-friendly error message - from chuk_term.ui import output - - output.error( - f"\n⏱️ Global streaming timeout after {global_timeout:.0f}s.\n" - f"The total streaming time exceeded the maximum allowed.\n" - f"You can increase this timeout with: --tool-timeout {global_timeout * 2:.0f}\n" - f"Or set MCP_STREAMING_GLOBAL_TIMEOUT={global_timeout * 2:.0f}" + logger.error( + "Global streaming timeout after %.0fs. " + "Increase with --tool-timeout %.0f or MCP_STREAMING_GLOBAL_TIMEOUT=%.0f", + global_timeout, + global_timeout * 2, + global_timeout * 2, ) self._interrupted = True @@ -519,16 +513,13 @@ async def _handle_non_streaming( **kwargs, ) -> dict[str, Any]: """Fallback for non-streaming clients.""" - from chuk_term.ui import output - start_time = time.time() - with output.loading("Generating response..."): - # Try to call client - if hasattr(client, "complete"): - result = await client.complete(messages=messages, tools=tools, **kwargs) - else: - raise RuntimeError("Client has no streaming or completion method") + logger.debug("Non-streaming fallback: generating response...") + if hasattr(client, "complete"): + result = await client.complete(messages=messages, tools=tools, **kwargs) + else: + raise RuntimeError("Client has no streaming or completion method") elapsed = time.time() - start_time diff --git a/src/mcp_cli/chat/tool_processor.py b/src/mcp_cli/chat/tool_processor.py index aa4a94a8..6a10492e 100644 --- a/src/mcp_cli/chat/tool_processor.py +++ b/src/mcp_cli/chat/tool_processor.py @@ -30,9 +30,11 @@ DEFAULT_MAX_CONSECUTIVE_TRANSPORT_FAILURES, TRANSPORT_ERROR_PATTERNS, ) -from chuk_ai_session_manager.guards import get_tool_state, SoftBlockReason +from chuk_ai_session_manager.guards import SoftBlockReason +from mcp_cli.chat.agent_tool_state import get_agent_tool_state from chuk_tool_processor.discovery import get_search_engine from mcp_cli.llm.content_models import ContentBlockType +from mcp_cli.agents.tools import _AGENT_TOOL_NAMES from mcp_cli.memory.tools import _MEMORY_TOOL_NAMES from mcp_cli.planning.tools import _PLAN_TOOL_NAMES from mcp_cli.utils.preferences import get_preference_manager @@ -45,6 +47,7 @@ # VM tools handled locally via MemoryManager, not routed to MCP ToolManager _VM_TOOL_NAMES = frozenset({"page_fault", "search_pages"}) +# _AGENT_TOOL_NAMES imported from mcp_cli.agents.tools (single source of truth) # _MEMORY_TOOL_NAMES imported from mcp_cli.memory.tools (single source of truth) # _PLAN_TOOL_NAMES imported from mcp_cli.planning.tools (single source of truth) @@ -223,6 +226,15 @@ async def process_tool_calls( ) continue + # ── Agent tool interception ──────────────────────────── + # agent_spawn, agent_stop, etc. are orchestration tools + # handled by AgentManager — not routed to MCP ToolManager. + if actual_tool in _AGENT_TOOL_NAMES: + await self._handle_agent_tool( + actual_tool, arguments, llm_tool_name, call_id + ) + continue + # DEBUG: Log exactly what the model sent for this tool call logger.info(f"TOOL CALL FROM MODEL: {llm_tool_name} id={call_id}") logger.info(f" raw_arguments: {raw_arguments}") @@ -255,7 +267,9 @@ async def process_tool_calls( continue # Check $vN references in arguments (dataflow validation) - tool_state = get_tool_state() + tool_state = get_agent_tool_state( + getattr(self.context, "agent_id", "default") + ) ref_check = tool_state.check_references(arguments) if not ref_check.valid: logger.warning( @@ -467,7 +481,7 @@ async def _on_tool_result(self, result: CTPToolResult) -> None: f"Tool result ({actual_tool_name}): success={success}, error='{result.error}'" ) - tool_state = get_tool_state() + tool_state = get_agent_tool_state(getattr(self.context, "agent_id", "default")) value_binding = None # Cache successful results and create value bindings @@ -795,6 +809,37 @@ async def _handle_plan_tool( self._add_tool_result_to_history(llm_tool_name, call_id, result_text) + async def _handle_agent_tool( + self, + tool_name: str, + arguments: dict, + llm_tool_name: str, + call_id: str, + ) -> None: + """Execute an agent orchestration tool (agent_spawn, agent_stop, etc.). + + Agent tools are internal operations that bypass the MCP ToolManager + and all guard checks. They delegate to the AgentManager stored on + the ChatContext. + """ + agent_manager = getattr(self.context, "agent_manager", None) + if agent_manager is None: + self._add_tool_result_to_history( + llm_tool_name, call_id, "Agent tools are not enabled." + ) + return + + logger.info("Agent tool %s called with args: %s", tool_name, arguments) + + from mcp_cli.agents.tools import handle_agent_tool + + caller_id = getattr(self.context, "agent_id", "default") + result_text = await handle_agent_tool( + tool_name, arguments, agent_manager, caller_agent_id=caller_id + ) + + self._add_tool_result_to_history(llm_tool_name, call_id, result_text) + async def _handle_vm_tool( self, tool_name: str, diff --git a/src/mcp_cli/chat/ui_manager.py b/src/mcp_cli/chat/ui_manager.py index 061ad0f9..8b6615c1 100644 --- a/src/mcp_cli/chat/ui_manager.py +++ b/src/mcp_cli/chat/ui_manager.py @@ -254,7 +254,7 @@ async def do_confirm_tool_execution(self, tool_name: str, arguments: Any) -> boo # Route to dashboard if clients are connected bridge = getattr(self.context, "dashboard_bridge", None) - if bridge is not None and bridge.server.has_clients: + if bridge is not None and bridge.has_clients: try: call_id = f"confirm-{id(arguments)}-{time.time_ns()}" fut = await bridge.request_tool_approval( diff --git a/src/mcp_cli/commands/sessions/new.py b/src/mcp_cli/commands/sessions/new.py index 774df6a1..6dc254f0 100644 --- a/src/mcp_cli/commands/sessions/new.py +++ b/src/mcp_cli/commands/sessions/new.py @@ -79,16 +79,21 @@ async def execute(self, **kwargs) -> CommandResult: try: from mcp_cli.dashboard.bridge import _envelope - await bridge.server.broadcast( - _envelope("CONVERSATION_HISTORY", {"messages": []}) + _aid = bridge.agent_id + await bridge.broadcast( + _envelope( + "CONVERSATION_HISTORY", + {"agent_id": _aid, "messages": []}, + ) ) config = bridge._build_config_state() if config: - await bridge.server.broadcast(_envelope("CONFIG_STATE", config)) - await bridge.server.broadcast( + await bridge.broadcast(_envelope("CONFIG_STATE", config)) + await bridge.broadcast( _envelope( "SESSION_STATE", { + "agent_id": _aid, "session_id": chat_context.session_id, "description": description, }, diff --git a/src/mcp_cli/commands/sessions/sessions.py b/src/mcp_cli/commands/sessions/sessions.py index a1fbcc1d..0b25e708 100644 --- a/src/mcp_cli/commands/sessions/sessions.py +++ b/src/mcp_cli/commands/sessions/sessions.py @@ -73,7 +73,11 @@ async def execute(self, **kwargs) -> CommandResult: action = args[0] if args else SessionAction.LIST session_id = args[1] if len(args) > 1 else None - store = SessionStore() + store = SessionStore( + agent_id=getattr(chat_context, "agent_id", "default") + if chat_context + else "default" + ) if action == SessionAction.LIST: sessions = store.list_sessions() diff --git a/src/mcp_cli/config/cli_options.py b/src/mcp_cli/config/cli_options.py index 35eda307..dfbdc31b 100644 --- a/src/mcp_cli/config/cli_options.py +++ b/src/mcp_cli/config/cli_options.py @@ -150,26 +150,25 @@ def process_options( pref_manager = get_preference_manager() - # Lazy import: chuk_term.ui is a UI dependency, only needed for user-facing warnings - from chuk_term.ui import output - # Filter out disabled servers if user_specified: # If user explicitly requested servers, check if they're disabled enabled_from_requested = [] for server in user_specified: if pref_manager.is_server_disabled(server): - output.warning(f"Server '{server}' is disabled and cannot be used") - output.hint( - f"To enable it, use: mcp-cli chat then /servers {server} enable" + logger.warning( + "Server '%s' is disabled. To enable: mcp-cli chat then /servers %s enable", + server, + server, ) else: enabled_from_requested.append(server) servers_list = enabled_from_requested if not servers_list and user_specified: - output.warning("All requested servers are disabled") - output.hint("Use 'mcp-cli servers' to see server status") + logger.warning( + "All requested servers are disabled. Use 'mcp-cli servers' to see status." + ) else: # No specific servers requested - filter out disabled ones from preferences enabled_servers = [] diff --git a/src/mcp_cli/config/defaults.py b/src/mcp_cli/config/defaults.py index 0c8ca659..f88518cd 100644 --- a/src/mcp_cli/config/defaults.py +++ b/src/mcp_cli/config/defaults.py @@ -329,6 +329,14 @@ """Seconds before showing 'initialization timed out' in host page.""" +# ================================================================ +# Agent Identity Defaults +# ================================================================ + +DEFAULT_AGENT_ID = "default" +"""Default agent identifier for multi-agent support.""" + + # ================================================================ # Dashboard Defaults # ================================================================ diff --git a/src/mcp_cli/dashboard/__init__.py b/src/mcp_cli/dashboard/__init__.py index 8f8172bb..31813f12 100644 --- a/src/mcp_cli/dashboard/__init__.py +++ b/src/mcp_cli/dashboard/__init__.py @@ -10,6 +10,7 @@ from __future__ import annotations from mcp_cli.dashboard.bridge import DashboardBridge +from mcp_cli.dashboard.router import AgentDescriptor, AgentRouter from mcp_cli.dashboard.server import DashboardServer -__all__ = ["DashboardBridge", "DashboardServer"] +__all__ = ["AgentDescriptor", "AgentRouter", "DashboardBridge", "DashboardServer"] diff --git a/src/mcp_cli/dashboard/bridge.py b/src/mcp_cli/dashboard/bridge.py index 44cce2d7..d47ea9ef 100644 --- a/src/mcp_cli/dashboard/bridge.py +++ b/src/mcp_cli/dashboard/bridge.py @@ -21,15 +21,17 @@ from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING, Any, Literal +from mcp_cli.config.defaults import DEFAULT_AGENT_ID from mcp_cli.dashboard.server import DashboardServer if TYPE_CHECKING: from mcp_cli.chat.chat_context import ChatContext + from mcp_cli.dashboard.router import AgentRouter logger = logging.getLogger(__name__) _PROTOCOL = "mcp-dashboard" -_VERSION = 1 +_VERSION = 2 def _envelope(msg_type: str, payload: dict[str, Any]) -> dict[str, Any]: @@ -48,8 +50,12 @@ def _now() -> str: class DashboardBridge: """Routes chat-engine events to connected browser dashboard clients.""" - def __init__(self, server: DashboardServer) -> None: - self.server = server + def __init__( + self, server: DashboardServer | AgentRouter, agent_id: str = DEFAULT_AGENT_ID + ) -> None: + from mcp_cli.dashboard.router import AgentRouter + + self.agent_id = agent_id self._turn_number: int = 0 self._ctx: ChatContext | None = None # Set this to inject user messages from the browser back into the chat engine @@ -65,10 +71,19 @@ def __init__(self, server: DashboardServer) -> None: self._seen_view_ids: set[str] = set() # Pending tool approval futures keyed by call_id self._pending_approvals: dict[str, asyncio.Future[bool]] = {} - # Wire server callbacks - server.on_browser_message = self._on_browser_message - server.on_client_connected = self._on_client_connected - server.on_client_disconnected = self.on_client_disconnected + + # Dual-mode: router-managed vs direct-wiring + if isinstance(server, AgentRouter): + self._router: AgentRouter | None = server + self.server: DashboardServer = server.server + # Router owns the server callbacks — do NOT wire them here + else: + self._router = None + self.server = server + # Legacy direct-wiring path + server.on_browser_message = self._on_browser_message + server.on_client_connected = self._on_client_connected + server.on_client_disconnected = self.on_client_disconnected def set_context(self, ctx: ChatContext) -> None: """Store a back-reference to ChatContext for history/config queries.""" @@ -81,13 +96,31 @@ async def on_shutdown(self) -> None: fut.set_result(False) self._pending_approvals.clear() + @property + def has_clients(self) -> bool: + """Whether any browser clients are connected.""" + if self._router is not None: + return self._router.has_clients + return self.server.has_clients + + async def _broadcast(self, envelope: dict[str, Any]) -> None: + """Dispatch a broadcast through the router or directly to the server.""" + if self._router is not None: + await self._router.broadcast_from_agent(self.agent_id, envelope) + else: + await self.server.broadcast(envelope) + + async def broadcast(self, envelope: dict[str, Any]) -> None: + """Public broadcast method for external callers (e.g. commands).""" + await self._broadcast(envelope) + async def on_client_disconnected(self) -> None: """Called when a browser client disconnects. If no clients remain, cancel all pending approval futures so the tool processor doesn't hang waiting for a response that will never come. """ - if not self.server.has_clients: + if not self.has_clients: for call_id, fut in list(self._pending_approvals.items()): if not fut.done(): fut.set_result(False) @@ -113,7 +146,7 @@ async def on_tool_result( payload: dict[str, Any] = { "tool_name": tool_name, "server_name": server_name, - "agent_id": "default", + "agent_id": self.agent_id, "call_id": call_id or "", "timestamp": _now(), "duration_ms": duration_ms, @@ -127,7 +160,7 @@ async def on_tool_result( # Discover new views declared in _meta.ui before broadcasting if isinstance(meta_ui, dict) and meta_ui.get("view"): await self._discover_view(meta_ui, server_name) - await self.server.broadcast(_envelope("TOOL_RESULT", payload)) + await self._broadcast(_envelope("TOOL_RESULT", payload)) async def on_agent_state( self, @@ -140,14 +173,14 @@ async def on_agent_state( if turn_number is not None: self._turn_number = turn_number payload: dict[str, Any] = { - "agent_id": "default", + "agent_id": self.agent_id, "status": status, "current_tool": current_tool, "turn_number": self._turn_number, "tokens_used": tokens_used, "budget_remaining": None, } - await self.server.broadcast(_envelope("AGENT_STATE", payload)) + await self._broadcast(_envelope("AGENT_STATE", payload)) async def on_message( self, @@ -159,22 +192,29 @@ async def on_message( ) -> None: """Called when a complete conversation message is emitted.""" payload: dict[str, Any] = { + "agent_id": self.agent_id, "role": role, "content": content, "streaming": streaming, "tool_calls": tool_calls, "reasoning": reasoning, } - await self.server.broadcast(_envelope("CONVERSATION_MESSAGE", payload)) + await self._broadcast(_envelope("CONVERSATION_MESSAGE", payload)) async def on_token(self, token: str, done: bool = False) -> None: """Called for each streamed LLM token (high-volume — only used by agent terminal).""" - payload: dict[str, Any] = {"token": token, "done": done} - await self.server.broadcast(_envelope("CONVERSATION_TOKEN", payload)) + payload: dict[str, Any] = { + "agent_id": self.agent_id, + "token": token, + "done": done, + } + await self._broadcast(_envelope("CONVERSATION_TOKEN", payload)) async def on_view_registry_update(self, views: list[dict[str, Any]]) -> None: """Called when the set of available views changes (server connect/disconnect).""" - await self.server.broadcast(_envelope("VIEW_REGISTRY", {"views": views})) + await self._broadcast( + _envelope("VIEW_REGISTRY", {"agent_id": self.agent_id, "views": views}) + ) async def _discover_view(self, meta_ui: dict[str, Any], server_name: str) -> None: """Register a new view from a _meta.ui block and broadcast VIEW_REGISTRY.""" @@ -335,7 +375,7 @@ async def _handle_request_config(self) -> None: """Browser requested current config — broadcast CONFIG_STATE.""" config = self._build_config_state() if config: - await self.server.broadcast(_envelope("CONFIG_STATE", config)) + await self._broadcast(_envelope("CONFIG_STATE", config)) async def _handle_switch_model(self, msg: dict[str, Any]) -> None: """Browser requested a model switch.""" @@ -357,7 +397,7 @@ async def _handle_switch_model(self, msg: dict[str, Any]) -> None: # Broadcast updated state config = self._build_config_state() if config: - await self.server.broadcast(_envelope("CONFIG_STATE", config)) + await self._broadcast(_envelope("CONFIG_STATE", config)) async def _handle_update_system_prompt(self, msg: dict[str, Any]) -> None: """Browser updated the system prompt.""" @@ -382,7 +422,7 @@ async def _handle_update_system_prompt(self, msg: dict[str, Any]) -> None: logger.warning("Dashboard UPDATE_SYSTEM_PROMPT failed: %s", exc) config = self._build_config_state() if config: - await self.server.broadcast(_envelope("CONFIG_STATE", config)) + await self._broadcast(_envelope("CONFIG_STATE", config)) # ------------------------------------------------------------------ # # Clear history handler # @@ -400,11 +440,17 @@ async def _handle_clear_history(self) -> None: except Exception as exc: logger.warning("Dashboard CLEAR_HISTORY failed: %s", exc) # Broadcast empty history + clear activity stream - await self.server.broadcast(_envelope("CONVERSATION_HISTORY", {"messages": []})) - await self.server.broadcast(_envelope("ACTIVITY_HISTORY", {"events": []})) + await self._broadcast( + _envelope( + "CONVERSATION_HISTORY", {"agent_id": self.agent_id, "messages": []} + ) + ) + await self._broadcast( + _envelope("ACTIVITY_HISTORY", {"agent_id": self.agent_id, "events": []}) + ) config = self._build_config_state() if config: - await self.server.broadcast(_envelope("CONFIG_STATE", config)) + await self._broadcast(_envelope("CONFIG_STATE", config)) # ------------------------------------------------------------------ # # New session handler # @@ -436,15 +482,22 @@ async def _handle_new_session(self, msg: dict[str, Any]) -> None: return # Broadcast fresh state to all clients + clear activity stream - await self.server.broadcast(_envelope("CONVERSATION_HISTORY", {"messages": []})) - await self.server.broadcast(_envelope("ACTIVITY_HISTORY", {"events": []})) + await self._broadcast( + _envelope( + "CONVERSATION_HISTORY", {"agent_id": self.agent_id, "messages": []} + ) + ) + await self._broadcast( + _envelope("ACTIVITY_HISTORY", {"agent_id": self.agent_id, "events": []}) + ) config = self._build_config_state() if config: - await self.server.broadcast(_envelope("CONFIG_STATE", config)) - await self.server.broadcast( + await self._broadcast(_envelope("CONFIG_STATE", config)) + await self._broadcast( _envelope( "SESSION_STATE", { + "agent_id": self.agent_id, "session_id": ctx.session_id, "description": description, }, @@ -466,6 +519,7 @@ async def _handle_request_sessions(self) -> None: return sessions = store.list_sessions() payload = { + "agent_id": self.agent_id, "sessions": [ { "session_id": s.session_id, @@ -481,7 +535,7 @@ async def _handle_request_sessions(self) -> None: ], "current_session_id": ctx.session_id, } - await self.server.broadcast(_envelope("SESSION_LIST", payload)) + await self._broadcast(_envelope("SESSION_LIST", payload)) except Exception as exc: logger.warning("Error building session list: %s", exc) @@ -517,19 +571,20 @@ async def _handle_switch_session(self, msg: dict[str, Any]) -> None: # Broadcast updated state history = self._build_conversation_history() - await self.server.broadcast( + await self._broadcast( _envelope("CONVERSATION_HISTORY", {"messages": history or []}) ) # Activity stream replay for loaded session activity = self._build_activity_history() - await self.server.broadcast( - _envelope("ACTIVITY_HISTORY", {"events": activity or []}) - ) + await self._broadcast(_envelope("ACTIVITY_HISTORY", {"events": activity or []})) config = self._build_config_state() if config: - await self.server.broadcast(_envelope("CONFIG_STATE", config)) - await self.server.broadcast( - _envelope("SESSION_STATE", {"session_id": session_id}) + await self._broadcast(_envelope("CONFIG_STATE", config)) + await self._broadcast( + _envelope( + "SESSION_STATE", + {"agent_id": self.agent_id, "session_id": session_id}, + ) ) # Refresh session list await self._handle_request_sessions() @@ -589,7 +644,7 @@ async def _handle_request_tools(self) -> None: """Browser requested current tool list — broadcast TOOL_REGISTRY.""" tools = await self._build_tool_registry() if tools is not None: - await self.server.broadcast(_envelope("TOOL_REGISTRY", {"tools": tools})) + await self._broadcast(_envelope("TOOL_REGISTRY", {"tools": tools})) async def _build_tool_registry(self) -> list[dict[str, Any]] | None: """Build tool registry payload from ChatContext's tool_manager.""" @@ -633,6 +688,7 @@ async def request_tool_approval( TOOL_APPROVAL_RESPONSE. Returns a Future that resolves to True/False. """ payload: dict[str, Any] = { + "agent_id": self.agent_id, "tool_name": tool_name, "arguments": self._serialise(arguments), "call_id": call_id, @@ -641,7 +697,7 @@ async def request_tool_approval( # Create a future that the tool processor can await fut: asyncio.Future[bool] = asyncio.get_running_loop().create_future() self._pending_approvals[call_id] = fut - await self.server.broadcast(_envelope("TOOL_APPROVAL_REQUEST", payload)) + await self._broadcast(_envelope("TOOL_APPROVAL_REQUEST", payload)) return fut async def _handle_tool_approval_response(self, msg: dict[str, Any]) -> None: @@ -667,6 +723,7 @@ async def on_plan_update( ) -> None: """Broadcast a plan update to the dashboard.""" payload: dict[str, Any] = { + "agent_id": self.agent_id, "plan_id": plan_id, "title": title, "steps": steps, @@ -675,7 +732,7 @@ async def on_plan_update( "error": error, "timestamp": _now(), } - await self.server.broadcast(_envelope("PLAN_UPDATE", payload)) + await self._broadcast(_envelope("PLAN_UPDATE", payload)) # ------------------------------------------------------------------ # # State builders # @@ -719,6 +776,7 @@ def _build_config_state(self) -> dict[str, Any] | None: sys_prompt = getattr(ctx, "_system_prompt", "") or "" return { + "agent_id": self.agent_id, "provider": provider, "model": model, "available_providers": available_providers, @@ -826,7 +884,7 @@ def _build_activity_history(self) -> list[dict[str, Any]] | None: "payload": { "tool_name": tool_name, "server_name": "", - "agent_id": "default", + "agent_id": self.agent_id, "call_id": call_id, "timestamp": None, "duration_ms": None, diff --git a/src/mcp_cli/dashboard/config.py b/src/mcp_cli/dashboard/config.py index 74ac9b63..082b2dd0 100644 --- a/src/mcp_cli/dashboard/config.py +++ b/src/mcp_cli/dashboard/config.py @@ -89,6 +89,14 @@ "type": "stream", "url": "/views/activity-stream.html", }, + { + "id": "builtin:agent-overview", + "name": "Agent Overview", + "source": "builtin", + "icon": "agents", + "type": "agents", + "url": "/views/agent-overview.html", + }, ] # ------------------------------------------------------------------ # diff --git a/src/mcp_cli/dashboard/launcher.py b/src/mcp_cli/dashboard/launcher.py index 3bf49fc6..35b16075 100644 --- a/src/mcp_cli/dashboard/launcher.py +++ b/src/mcp_cli/dashboard/launcher.py @@ -6,6 +6,7 @@ import logging import webbrowser +from mcp_cli.dashboard.router import AgentRouter from mcp_cli.dashboard.server import DashboardServer logger = logging.getLogger(__name__) @@ -14,7 +15,7 @@ async def launch_dashboard( port: int = 0, no_browser: bool = False, -) -> tuple[DashboardServer, int]: +) -> tuple[DashboardServer, AgentRouter, int]: """Start the dashboard server and (optionally) open the browser. Args: @@ -22,10 +23,11 @@ async def launch_dashboard( no_browser: If True, do not open the browser (URL is logged at INFO level). Returns: - (server, bound_port) — the started server and its actual port. + (server, router, bound_port) — the started server, router, and actual port. """ server = DashboardServer() bound_port = await server.start(port) + router = AgentRouter(server) url = f"http://localhost:{bound_port}" logger.info("Dashboard available at %s", url) @@ -36,4 +38,4 @@ async def launch_dashboard( except Exception as exc: logger.warning("Could not open browser: %s", exc) - return server, bound_port + return server, router, bound_port diff --git a/src/mcp_cli/dashboard/router.py b/src/mcp_cli/dashboard/router.py new file mode 100644 index 00000000..df4e45a9 --- /dev/null +++ b/src/mcp_cli/dashboard/router.py @@ -0,0 +1,319 @@ +# mcp_cli/dashboard/router.py +"""AgentRouter — routes messages between browser clients and DashboardBridge instances. + +In single-agent mode the router has exactly one bridge and behaves identically +to the legacy direct-wiring path. In multi-agent mode it tracks per-client +focus, sends AGENT_LIST on connect, and handles FOCUS_AGENT switching. +""" + +from __future__ import annotations + +import asyncio +import logging +from dataclasses import asdict, dataclass +from typing import TYPE_CHECKING, Any, Literal + +from mcp_cli.dashboard.server import DashboardServer + +if TYPE_CHECKING: + from mcp_cli.dashboard.bridge import DashboardBridge + +logger = logging.getLogger(__name__) + +# ------------------------------------------------------------------ # +# Protocol helpers (mirror bridge.py) # +# ------------------------------------------------------------------ # + +_PROTOCOL = "mcp-dashboard" +_VERSION = 2 + + +def _envelope(msg_type: str, payload: dict[str, Any]) -> dict[str, Any]: + return { + "protocol": _PROTOCOL, + "version": _VERSION, + "type": msg_type, + "payload": payload, + } + + +# ------------------------------------------------------------------ # +# AgentDescriptor # +# ------------------------------------------------------------------ # + + +@dataclass +class AgentDescriptor: + """Lightweight descriptor for an agent visible to the dashboard.""" + + agent_id: str + name: str + role: str = "" + status: Literal["active", "paused", "completed", "failed"] = "active" + model: str = "" + provider: str = "" + session_id: str = "" + parent_agent_id: str | None = None + tool_count: int = 0 + message_count: int = 0 + created_at: str = "" + + +# ------------------------------------------------------------------ # +# AgentRouter # +# ------------------------------------------------------------------ # + + +class AgentRouter: + """Routes dashboard messages between the server and one or more bridges.""" + + def __init__(self, server: DashboardServer) -> None: + self.server = server + self._bridges: dict[str, DashboardBridge] = {} + self._agent_descriptors: dict[str, AgentDescriptor] = {} + # Per-client focus: ws → agent_id + self._client_focus: dict[Any, str] = {} + # Per-client subscriptions: ws → set of agent_ids ("*" = all) + self._client_subscriptions: dict[Any, set[str]] = {} + + # Own the three server callbacks + server.on_browser_message = self._on_browser_message + server.on_client_connected = self._on_client_connected + server.on_client_disconnected = self._on_client_disconnected + + # ------------------------------------------------------------------ # + # Properties # + # ------------------------------------------------------------------ # + + @property + def has_clients(self) -> bool: + """Proxy to server.has_clients.""" + return self.server.has_clients + + @property + def _default_agent_id(self) -> str: + """First registered bridge id, fallback 'default'.""" + if self._bridges: + return next(iter(self._bridges)) + return "default" + + # ------------------------------------------------------------------ # + # Bridge registration # + # ------------------------------------------------------------------ # + + def register_agent( + self, + agent_id: str, + bridge: DashboardBridge, + descriptor: AgentDescriptor | None = None, + ) -> None: + """Register a bridge for the given agent_id.""" + self._bridges[agent_id] = bridge + if descriptor is None: + descriptor = AgentDescriptor(agent_id=agent_id, name=agent_id) + self._agent_descriptors[agent_id] = descriptor + logger.debug("AgentRouter: registered agent %s", agent_id) + # Notify connected browsers + if self.server.has_clients: + try: + loop = asyncio.get_running_loop() + loop.create_task(self._broadcast_agent_registered(descriptor)) + except RuntimeError: + pass # no running loop yet + + def unregister_agent(self, agent_id: str) -> None: + """Unregister a bridge for the given agent_id.""" + self._bridges.pop(agent_id, None) + self._agent_descriptors.pop(agent_id, None) + # Re-focus clients that were focused on the removed agent + default_id = self._default_agent_id + for ws, focused_id in list(self._client_focus.items()): + if focused_id == agent_id: + self._client_focus[ws] = default_id + logger.debug("AgentRouter: unregistered agent %s", agent_id) + # Notify connected browsers + if self.server.has_clients: + try: + loop = asyncio.get_running_loop() + loop.create_task(self._broadcast_agent_unregistered(agent_id)) + except RuntimeError: + pass + + # ------------------------------------------------------------------ # + # Agent status # + # ------------------------------------------------------------------ # + + async def update_agent_status(self, agent_id: str, status: str) -> None: + """Update an agent's status and broadcast AGENT_STATUS.""" + desc = self._agent_descriptors.get(agent_id) + if desc: + desc.status = status # type: ignore[assignment] + await self.broadcast_global( + _envelope("AGENT_STATUS", {"agent_id": agent_id, "status": status}) + ) + + # ------------------------------------------------------------------ # + # Outbound (bridge → browser) # + # ------------------------------------------------------------------ # + + async def broadcast_from_agent( + self, agent_id: str, message: dict[str, Any] + ) -> None: + """Broadcast a message from a specific agent. + + If no clients have subscriptions configured, broadcasts to all + (backward compat). Otherwise, only sends to clients subscribed + to this agent_id (or to ``"*"``). + """ + if not self._client_subscriptions: + # No subscriptions configured — broadcast to all (default) + await self.server.broadcast(message) + return + + for ws, subs in list(self._client_subscriptions.items()): + if "*" in subs or agent_id in subs: + try: + await self.server.send_to_client(ws, message) + except Exception: + pass # client may have disconnected + + async def broadcast_global(self, message: dict[str, Any]) -> None: + """Broadcast a message not scoped to any agent (e.g. AGENT_LIST).""" + await self.server.broadcast(message) + + async def send_to_client(self, ws: Any, message: dict[str, Any]) -> None: + """Send a message to a specific WebSocket client.""" + await self.server.send_to_client(ws, message) + + # ------------------------------------------------------------------ # + # Agent list helpers # + # ------------------------------------------------------------------ # + + async def _send_agent_list(self, ws: Any) -> None: + """Send AGENT_LIST to a specific client.""" + agents = [asdict(d) for d in self._agent_descriptors.values()] + await self.server.send_to_client( + ws, _envelope("AGENT_LIST", {"agents": agents}) + ) + + async def _broadcast_agent_registered(self, desc: AgentDescriptor) -> None: + await self.broadcast_global(_envelope("AGENT_REGISTERED", asdict(desc))) + + async def _broadcast_agent_unregistered(self, agent_id: str) -> None: + await self.broadcast_global( + _envelope("AGENT_UNREGISTERED", {"agent_id": agent_id}) + ) + + # ------------------------------------------------------------------ # + # Focus management # + # ------------------------------------------------------------------ # + + async def _handle_focus_agent(self, msg: dict[str, Any], ws: Any) -> None: + """Browser client changed which agent it's viewing.""" + agent_id = msg.get("agent_id") or self._default_agent_id + if ws is None: + return + self._client_focus[ws] = agent_id + logger.debug("AgentRouter: client focused on %s", agent_id) + # Replay focused agent's state to this client only + bridge = self._bridges.get(agent_id) + if bridge: + await bridge._on_client_connected(ws) + + def _handle_subscribe(self, msg: dict[str, Any], ws: Any) -> None: + """Handle a SUBSCRIBE message from a browser client.""" + if ws is None: + return + agents = msg.get("agents", []) + is_global = msg.get("global", False) + subs: set[str] = set(agents) + if is_global: + subs.add("*") + self._client_subscriptions[ws] = subs + logger.debug("AgentRouter: client subscribed to %s", subs) + + async def _handle_agent_message(self, msg: dict[str, Any]) -> None: + """Route an AGENT_MESSAGE from browser to target agent's bridge.""" + to_agent = msg.get("to_agent") + content = msg.get("content", "") + from_agent = msg.get("from_agent", "browser") + if not to_agent: + return + bridge = self._bridges.get(to_agent) + if bridge: + # Inject as a user message annotated with source + await bridge._on_browser_message( + {"type": "USER_MESSAGE", "content": f"[From {from_agent}]: {content}"} + ) + + # ------------------------------------------------------------------ # + # Inbound callbacks (browser → bridge) # + # ------------------------------------------------------------------ # + + async def _on_browser_message(self, msg: dict[str, Any], ws: Any = None) -> None: + """Route an inbound browser message to the appropriate bridge.""" + msg_type = msg.get("type") + + # Handle router-level message types + if msg_type == "FOCUS_AGENT": + await self._handle_focus_agent(msg, ws) + return + if msg_type == "REQUEST_AGENT_LIST": + if ws is not None: + await self._send_agent_list(ws) + return + if msg_type == "AGENT_MESSAGE": + await self._handle_agent_message(msg) + return + if msg_type == "SUBSCRIBE": + self._handle_subscribe(msg, ws) + return + + # Route to bridge: explicit agent_id > client focus > sole bridge + target_id = msg.get("agent_id") + bridge: DashboardBridge | None = None + + if target_id and target_id in self._bridges: + bridge = self._bridges[target_id] + elif ws is not None and ws in self._client_focus: + target_id = self._client_focus[ws] + bridge = self._bridges.get(target_id) + elif len(self._bridges) == 1: + bridge = next(iter(self._bridges.values())) + elif target_id: + logger.debug( + "AgentRouter: unknown agent_id %r in browser message", target_id + ) + return + else: + logger.debug( + "AgentRouter: no agent_id in message and %d bridges registered", + len(self._bridges), + ) + return + + if bridge is not None: + await bridge._on_browser_message(msg) + + async def _on_client_connected(self, ws: Any) -> None: + """Send agent list and replay focused agent state to a new client.""" + # Send AGENT_LIST first + await self._send_agent_list(ws) + # Default focus: first registered agent + default_id = self._default_agent_id + self._client_focus[ws] = default_id + # Default subscription: all agents + self._client_subscriptions[ws] = {"*"} + # Replay only the focused agent's state + bridge = self._bridges.get(default_id) + if bridge: + await bridge._on_client_connected(ws) + + async def _on_client_disconnected(self, ws: Any = None) -> None: + """Notify all registered bridges and clean up per-client state.""" + for bridge in self._bridges.values(): + await bridge.on_client_disconnected() + # Clean up per-client tracking for the disconnected ws + if ws is not None: + self._client_focus.pop(ws, None) + self._client_subscriptions.pop(ws, None) diff --git a/src/mcp_cli/dashboard/server.py b/src/mcp_cli/dashboard/server.py index 1832eb06..67b13918 100644 --- a/src/mcp_cli/dashboard/server.py +++ b/src/mcp_cli/dashboard/server.py @@ -15,6 +15,7 @@ import asyncio import http +import inspect import json import logging import mimetypes @@ -43,11 +44,15 @@ def __init__(self) -> None: self._clients: set[ServerConnection] = set() self._server: Any = None # Called when a browser user sends USER_MESSAGE / USER_ACTION / REQUEST_TOOL - self.on_browser_message: Callable[[dict[str, Any]], Any] | None = None + self.on_browser_message: Callable[..., Any] | None = None # Called when a new WebSocket client connects (before message loop starts) self.on_client_connected: Callable[[Any], Any] | None = None - # Called when a WebSocket client disconnects - self.on_client_disconnected: Callable[[], Any] | None = None + # Called when a WebSocket client disconnects (receives ws) + self.on_client_disconnected: Callable[..., Any] | None = None + # Cached arity of on_browser_message callback (None = not yet checked) + self._browser_msg_arity: int | None = None + # Track callback identity for arity cache invalidation + self._browser_msg_cb_id: int | None = None @property def has_clients(self) -> bool: @@ -96,6 +101,16 @@ async def broadcast(self, msg: dict[str, Any]) -> None: for c in dead: self._clients.discard(c) + async def send_to_client(self, ws: ServerConnection, msg: dict[str, Any]) -> None: + """Send a JSON message to a specific WebSocket client. + + Discards the client from the active set if the send fails. + """ + try: + await ws.send(json.dumps(msg)) + except Exception: + self._clients.discard(ws) + # ------------------------------------------------------------------ # # WebSocket handler # # ------------------------------------------------------------------ # @@ -115,7 +130,7 @@ async def _ws_handler(self, ws: ServerConnection) -> None: try: async for raw in ws: if isinstance(raw, str): - await self._handle_browser_message(raw) + await self._handle_browser_message(raw, ws) except websockets.ConnectionClosed: pass finally: @@ -126,13 +141,15 @@ async def _ws_handler(self, ws: ServerConnection) -> None: ) if self.on_client_disconnected is not None: try: - result = self.on_client_disconnected() + result = self.on_client_disconnected(ws) if asyncio.iscoroutine(result): await result except Exception as exc: logger.debug("Error in client disconnected callback: %s", exc) - async def _handle_browser_message(self, raw: str) -> None: + async def _handle_browser_message( + self, raw: str, ws: ServerConnection | None = None + ) -> None: try: msg = json.loads(raw) except json.JSONDecodeError: @@ -140,7 +157,19 @@ async def _handle_browser_message(self, raw: str) -> None: return if self.on_browser_message: try: - result = self.on_browser_message(msg) + # Detect callback arity: 2-arg (msg, ws) vs 1-arg (msg) + cb_id = id(self.on_browser_message) + if self._browser_msg_arity is None or self._browser_msg_cb_id != cb_id: + self._browser_msg_cb_id = cb_id + try: + sig = inspect.signature(self.on_browser_message) + self._browser_msg_arity = len(sig.parameters) + except (ValueError, TypeError): + self._browser_msg_arity = 1 + if self._browser_msg_arity >= 2 and ws is not None: + result = self.on_browser_message(msg, ws) + else: + result = self.on_browser_message(msg) if asyncio.iscoroutine(result): await result except Exception as exc: diff --git a/src/mcp_cli/dashboard/static/shell.html b/src/mcp_cli/dashboard/static/shell.html index 691f8297..47f92f25 100644 --- a/src/mcp_cli/dashboard/static/shell.html +++ b/src/mcp_cli/dashboard/static/shell.html @@ -482,6 +482,52 @@ .tb-btn { font-size: 11px; padding: 2px 6px; } } +/* ── Agent tab bar ─────────────────────────────────────────────── */ +#agent-tabs { + display: none; + align-items: center; + gap: 4px; + padding: 0 12px; + height: 28px; + min-height: 28px; + background: var(--dash-bg); + border-bottom: 1px solid var(--dash-border); + flex-shrink: 0; + user-select: none; + overflow-x: auto; +} +#agent-tabs.visible { display: flex; } +.agent-tab { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 10px; + border: 1px solid transparent; + border-radius: var(--dash-radius); + background: transparent; + color: var(--dash-fg-muted); + font-size: 11px; + font-family: var(--dash-font-ui); + cursor: pointer; + white-space: nowrap; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} +.agent-tab:hover { background: var(--dash-bg-hover); color: var(--dash-fg); } +.agent-tab.focused { + background: var(--dash-bg-surface); + color: var(--dash-fg); + border-color: var(--dash-accent); +} +.agent-tab-indicator { + width: 6px; height: 6px; + border-radius: 50%; + flex-shrink: 0; +} +.agent-tab-indicator.active { background: var(--dash-success); } +.agent-tab-indicator.paused { background: var(--dash-warning); } +.agent-tab-indicator.completed { background: var(--dash-fg-muted); } +.agent-tab-indicator.failed { background: var(--dash-error); } + /* (view-stash removed — iframes now live permanently in #view-overlay) */ @@ -527,6 +573,9 @@ + +
    +
    @@ -618,6 +667,12 @@

    Tool Confirmation Required

    const viewPool = new Map(); // viewId → { iframe, ready, accepts, _readyTimeout } const popoutWindows = new Map(); // viewId → { win, intervalId } +// Multi-agent state +let agentList = []; // array of agent descriptors from AGENT_LIST +let focusedAgentId = null; // currently focused agent id +const AGENT_COLORS = ['#7aa2f7','#9ece6a','#e0af68','#f7768e','#7dcfff','#bb9af7','#ff9e64','#73daca']; +const agentColorMap = new Map(); // agent_id → stable color + // ================================================================ // WebSocket (exponential backoff reconnect) // ================================================================ @@ -636,6 +691,8 @@

    Tool Confirmation Required

    _wsBackoff = 1000; // reset on success document.getElementById('conn-dot').classList.add('connected'); showToast('success', 'Connected to dashboard server', 2000); + // Request agent list (router will send AGENT_LIST) + sendToBridge({ type: 'REQUEST_AGENT_LIST' }); // Request current config (model, servers, system prompt) sendToBridge({ type: 'REQUEST_CONFIG' }); // Request tool registry @@ -670,6 +727,82 @@

    Tool Confirmation Required

    } } +// ================================================================ +// Multi-agent helpers +// ================================================================ +function agentColor(agentId) { + if (!agentColorMap.has(agentId)) { + agentColorMap.set(agentId, AGENT_COLORS[agentColorMap.size % AGENT_COLORS.length]); + } + return agentColorMap.get(agentId); +} + +function renderAgentTabs() { + const bar = document.getElementById('agent-tabs'); + bar.innerHTML = ''; + if (agentList.length <= 1) { bar.classList.remove('visible'); return; } + bar.classList.add('visible'); + for (const agent of agentList) { + const btn = document.createElement('button'); + btn.className = 'agent-tab' + (agent.agent_id === focusedAgentId ? ' focused' : ''); + const dot = document.createElement('span'); + dot.className = 'agent-tab-indicator ' + (agent.status || 'active'); + btn.appendChild(dot); + const label = document.createElement('span'); + label.textContent = agent.name || agent.agent_id; + btn.appendChild(label); + btn.addEventListener('click', () => focusAgent(agent.agent_id)); + bar.appendChild(btn); + } +} + +function focusAgent(agentId) { + if (agentId === focusedAgentId) return; + focusedAgentId = agentId; + renderAgentTabs(); + sendToBridge({ type: 'FOCUS_AGENT', agent_id: agentId }); + // Update subscription to include focused agent + global + sendToBridge({ type: 'SUBSCRIBE', agents: [agentId], global: true }); +} + +function handleAgentList(payload) { + agentList = payload.agents || []; + if (!focusedAgentId && agentList.length > 0) { + focusedAgentId = agentList[0].agent_id; + } + renderAgentTabs(); +} + +function handleAgentRegistered(payload) { + const existing = agentList.find(a => a.agent_id === payload.agent_id); + if (!existing) agentList.push(payload); + if (!focusedAgentId && agentList.length > 0) { + focusedAgentId = agentList[0].agent_id; + } + renderAgentTabs(); +} + +function handleAgentUnregistered(payload) { + agentList = agentList.filter(a => a.agent_id !== payload.agent_id); + if (focusedAgentId === payload.agent_id) { + focusedAgentId = agentList.length > 0 ? agentList[0].agent_id : null; + } + renderAgentTabs(); +} + +function handleAgentStatus(payload) { + const agent = agentList.find(a => a.agent_id === payload.agent_id); + if (agent) agent.status = payload.status; + renderAgentTabs(); +} + +function isMultiAgent() { return agentList.length > 1; } +function isFocusedAgent(msg) { + if (!isMultiAgent()) return true; + const aid = (msg.payload && msg.payload.agent_id) || msg.agent_id; + return !aid || aid === focusedAgentId; +} + // ================================================================ // Bridge message routing // ================================================================ @@ -686,25 +819,49 @@

    Tool Confirmation Required

    rebuildAddPanelMenu(); break; + // ── Agent lifecycle messages ─────────────────────────────── + case 'AGENT_LIST': + handleAgentList(msg.payload); + broadcastToViewType('agents', 'AGENT_LIST', msg.payload); + break; + + case 'AGENT_REGISTERED': + handleAgentRegistered(msg.payload); + broadcastToViewType('agents', 'AGENT_REGISTERED', msg.payload); + break; + + case 'AGENT_UNREGISTERED': + handleAgentUnregistered(msg.payload); + broadcastToViewType('agents', 'AGENT_UNREGISTERED', msg.payload); + break; + + case 'AGENT_STATUS': + handleAgentStatus(msg.payload); + broadcastToViewType('agents', 'AGENT_STATUS', msg.payload); + break; + + // ── Agent-scoped messages ──────────────────────────────── case 'TOOL_RESULT': - routeToViews('TOOL_RESULT', msg.payload); + sendToActivityStream('TOOL_RESULT', msg.payload); + if (isFocusedAgent(msg)) routeToViews('TOOL_RESULT', msg.payload); break; case 'AGENT_STATE': - broadcastToViews('AGENT_STATE', msg.payload); + if (isFocusedAgent(msg)) broadcastToViews('AGENT_STATE', msg.payload); + broadcastToViewType('agents', 'AGENT_STATE', msg.payload); break; case 'CONVERSATION_MESSAGE': - broadcastToViewType('conversation', 'CONVERSATION_MESSAGE', msg.payload); sendToActivityStream('CONVERSATION_MESSAGE', msg.payload); + if (isFocusedAgent(msg)) broadcastToViewType('conversation', 'CONVERSATION_MESSAGE', msg.payload); break; case 'CONVERSATION_TOKEN': - broadcastToViewType('conversation', 'CONVERSATION_TOKEN', msg.payload); + if (isFocusedAgent(msg)) broadcastToViewType('conversation', 'CONVERSATION_TOKEN', msg.payload); break; case 'CONVERSATION_HISTORY': - broadcastToViewType('conversation', 'CONVERSATION_HISTORY', msg.payload); + if (isFocusedAgent(msg)) broadcastToViewType('conversation', 'CONVERSATION_HISTORY', msg.payload); break; case 'ACTIVITY_HISTORY': @@ -831,7 +988,7 @@

    Tool Confirmation Required

    postToIframe(view.iframe, 'INIT', { view_id: viewId, panel_id: panel.panelId, - agent_id: null, + agent_id: focusedAgentId, theme: themeToCSS(themeObj), dimensions: dims, }); @@ -1003,6 +1160,7 @@

    Tool Confirmation Required

    const wasReady = view.ready; view.ready = true; view.accepts = payload.accepts || []; + view.agent_scope = payload.agent_scope || null; // "focused" | "all" | specific agent_id if (view._readyTimeout) { clearTimeout(view._readyTimeout); view._readyTimeout = null; } const panel = findPanelHostingView(viewId); if (!panel) return; @@ -1247,6 +1405,7 @@

    Tool Confirmation Required

    if (viewId === 'builtin:activity-stream') return '◈'; if (viewId === 'builtin:tool-browser') return '🔧'; if (viewId === 'builtin:plan-viewer') return '📋'; + if (viewId === 'builtin:agent-overview') return '👥'; const v = viewRegistry.find(v => v.id === viewId); return v?.icon || '◻'; } @@ -1257,6 +1416,7 @@

    Tool Confirmation Required

    if (viewId === 'builtin:activity-stream') return 'Activity Stream'; if (viewId === 'builtin:tool-browser') return 'Tool Browser'; if (viewId === 'builtin:plan-viewer') return 'Plan Viewer'; + if (viewId === 'builtin:agent-overview') return 'Agent Overview'; const v = viewRegistry.find(v => v.id === viewId); return v ? v.name : viewId; } @@ -1304,7 +1464,7 @@

    Tool Confirmation Required

    win.postMessage({ protocol: PROTOCOL, version: VERSION, type: 'INIT', payload: { - view_id: viewId, panel_id: null, agent_id: null, + view_id: viewId, panel_id: null, agent_id: focusedAgentId, theme: themeToCSS(themeObj), dimensions: { width: win.innerWidth || 900, height: win.innerHeight || 600 }, }, @@ -2020,6 +2180,7 @@

    Tool Confirmation Required

    { id: 'builtin:activity-stream', name: 'Activity Stream', source: 'builtin', icon: '◈', type: 'stream', url: '/views/activity-stream.html' }, { id: 'builtin:tool-browser', name: 'Tool Browser', source: 'builtin', icon: '🔧', type: 'tools', url: '/views/tool-browser.html' }, { id: 'builtin:plan-viewer', name: 'Plan Viewer', source: 'builtin', icon: '📋', type: 'plan', url: '/views/plan-viewer.html' }, + { id: 'builtin:agent-overview', name: 'Agent Overview', source: 'builtin', icon: '👥', type: 'agents', url: '/views/agent-overview.html' }, ]; rebuildAddPanelMenu(); diff --git a/src/mcp_cli/dashboard/static/views/activity-stream.html b/src/mcp_cli/dashboard/static/views/activity-stream.html index 45969ec5..a5b5b0ee 100644 --- a/src/mcp_cli/dashboard/static/views/activity-stream.html +++ b/src/mcp_cli/dashboard/static/views/activity-stream.html @@ -180,6 +180,25 @@ cursor: pointer; z-index: 10; } +/* Agent badge */ +.event-agent-badge { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 0 5px; + border-radius: 8px; + font-size: 10px; + background: var(--dash-bg); + border: 1px solid var(--dash-border); + white-space: nowrap; + flex-shrink: 0; +} +.event-agent-dot { + width: 6px; height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + #events-wrapper { flex: 1; position: relative; overflow: hidden; display: flex; flex-direction: column; } @@ -193,6 +212,7 @@ +
    @@ -209,9 +229,14 @@ let filterText = ''; let filterServer = ''; let filterStatus = ''; +let filterAgent = ''; let autoScroll = true; let newCount = 0; let servers = new Set(); +let agents = new Set(); +let _multiAgent = false; +const AGENT_COLORS = ['#7aa2f7','#9ece6a','#e0af68','#f7768e','#7dcfff','#bb9af7','#ff9e64','#73daca']; +const agentColorMap = new Map(); // Virtual scroll threshold const VIRT_THRESHOLD = 500; @@ -221,6 +246,20 @@ const searchInput = document.getElementById('search-input'); const serverSel = document.getElementById('server-filter'); const statusSel = document.getElementById('status-filter'); +const agentSel = document.getElementById('agent-filter'); + +function agentColor(agentId) { + if (!agentColorMap.has(agentId)) { + agentColorMap.set(agentId, AGENT_COLORS[agentColorMap.size % AGENT_COLORS.length]); + } + return agentColorMap.get(agentId); +} + +function agentBadgeHtml(agentId) { + if (!_multiAgent || !agentId) return ''; + const color = agentColor(agentId); + return `${esc(agentId)}`; +} // ── postMessage handler ───────────────────────────────────────── window.addEventListener('message', (evt) => { @@ -319,6 +358,21 @@ } } + // Track agents for filter + badge + const aid = payload.agent_id; + if (aid && !agents.has(aid)) { + agents.add(aid); + const opt = document.createElement('option'); + opt.value = aid; + opt.textContent = aid; + agentSel.appendChild(opt); + if (agents.size > 1 && !_multiAgent) { + _multiAgent = true; + agentSel.style.display = ''; + rerender(); // re-render to add badges to existing cards + } + } + if (matchesFilter(ev)) { renderEvent(ev); } @@ -329,6 +383,7 @@ if (filterStatus === 'ok' && p.success === false) return false; if (filterStatus === 'err' && p.success !== false && ev.kind === 'tool') return false; if (filterServer && p.server_name !== filterServer) return false; + if (filterAgent && p.agent_id !== filterAgent) return false; if (filterText) { const haystack = JSON.stringify(p).toLowerCase(); if (!haystack.includes(filterText)) return false; @@ -403,6 +458,7 @@ card.innerHTML = `
    + ${agentBadgeHtml(p.agent_id)} ${esc(p.tool_name || '')} ${esc(p.server_name || '')} ${esc(durStr)} @@ -435,6 +491,7 @@ card.innerHTML = `
    + ${agentBadgeHtml(p.agent_id)} ${icon} ${esc(status)}${tool} ${tsStr}${tokens}
    `; @@ -501,6 +558,7 @@ card.innerHTML = `
    + ${agentBadgeHtml(p.agent_id)} ${icon} Plan: ${esc(p.title || 'Untitled')} ${progress}
    @@ -546,6 +604,7 @@ card.innerHTML = `
    + ${agentBadgeHtml(p.agent_id)} 💡 Thinking ${tsStr}
    @@ -579,6 +638,7 @@ card.innerHTML = `
    + ${agentBadgeHtml(p.agent_id)} ⚙ Calling: ${esc(toolNames)} ${tsStr}
    @@ -677,6 +737,10 @@ filterStatus = statusSel.value; rerender(); }); +agentSel.addEventListener('change', () => { + filterAgent = agentSel.value; + rerender(); +}); function rerender() { eventsEl.innerHTML = ''; @@ -691,12 +755,16 @@ // Clear current state and replay allEvents = []; servers = new Set(); + agents = new Set(); + _multiAgent = false; eventsEl.innerHTML = ''; autoScroll = true; newCount = 0; - // Rebuild server filter options + // Rebuild filter options serverSel.innerHTML = ''; + agentSel.innerHTML = ''; + agentSel.style.display = 'none'; for (const ev of events) { // Each event has { type: 'TOOL_RESULT'|'CONVERSATION_MESSAGE', payload: {...} } diff --git a/src/mcp_cli/dashboard/static/views/agent-overview.html b/src/mcp_cli/dashboard/static/views/agent-overview.html new file mode 100644 index 00000000..bb1089b1 --- /dev/null +++ b/src/mcp_cli/dashboard/static/views/agent-overview.html @@ -0,0 +1,284 @@ + + + + + +Agent Overview + + + + +
    +
    Waiting for agents...
    +
    + + + + diff --git a/src/mcp_cli/dashboard/static/views/agent-terminal.html b/src/mcp_cli/dashboard/static/views/agent-terminal.html index 63381950..6c79ee9b 100644 --- a/src/mcp_cli/dashboard/static/views/agent-terminal.html +++ b/src/mcp_cli/dashboard/static/views/agent-terminal.html @@ -321,6 +321,7 @@ let streamingBubble = null; // current streaming message bubble let streamingContent = ''; let _hadStreamingTokens = false; // true when CONVERSATION_TOKEN events built current response +let _agentName = ''; // agent_id from INIT (shown in status bar when not "default") // Configure marked if (typeof marked !== 'undefined') { @@ -335,6 +336,9 @@ switch (msg.type) { case 'INIT': applyTheme(msg.payload.theme || {}); + if (msg.payload.agent_id && msg.payload.agent_id !== 'default') { + _agentName = msg.payload.agent_id; + } sendReady(); break; case 'THEME': @@ -564,6 +568,7 @@ if (status === 'tool_calling' && current_tool) text = `calling ${current_tool}…`; else if (status === 'thinking') text = 'thinking…'; else if (status === 'idle') text = 'idle'; + if (_agentName) text = `[${_agentName}] ${text}`; statusText.textContent = text; if (tokens_used) _totalTokens += tokens_used; diff --git a/src/mcp_cli/main.py b/src/mcp_cli/main.py index e35b0e02..113eb558 100644 --- a/src/mcp_cli/main.py +++ b/src/mcp_cli/main.py @@ -165,6 +165,11 @@ def main_callback( "--dashboard-port", help="Dashboard HTTP port (0 = auto-select starting at 9120)", ), + multi_agent: bool = typer.Option( + False, + "--multi-agent", + help="Enable multi-agent orchestration tools (agent_spawn, agent_stop, etc.). Implies --dashboard.", + ), ) -> None: """MCP CLI - If no subcommand is given, start chat mode.""" @@ -395,6 +400,7 @@ async def _start_chat(): dashboard=dashboard, no_browser=no_browser, dashboard_port=dashboard_port, + multi_agent=multi_agent, ) logger.debug(f"Chat mode completed with success: {success}") except asyncio.TimeoutError: @@ -514,6 +520,11 @@ def _chat_command( "--dashboard-port", help="Dashboard HTTP port (0 = auto-select starting at 9120)", ), + multi_agent: bool = typer.Option( + False, + "--multi-agent", + help="Enable multi-agent orchestration tools (agent_spawn, agent_stop, etc.). Implies --dashboard.", + ), ) -> None: """Start chat mode (same as default behavior without subcommand).""" # Re-configure logging based on user options @@ -652,6 +663,7 @@ async def _start_chat(): dashboard=dashboard, no_browser=no_browser, dashboard_port=dashboard_port, + multi_agent=multi_agent, ) logger.debug(f"Chat mode completed with success: {success}") except asyncio.TimeoutError: diff --git a/src/mcp_cli/tools/manager.py b/src/mcp_cli/tools/manager.py index 94867364..0f7a1c68 100644 --- a/src/mcp_cli/tools/manager.py +++ b/src/mcp_cli/tools/manager.py @@ -178,14 +178,12 @@ async def initialize( """ self._on_progress = on_progress try: - from chuk_term.ui import output - self._report_progress("Loading server configuration...") # Load config and detect server types (file I/O → off event loop) config = await asyncio.to_thread(self._config_loader.load) if not config: - output.warning("No config found, initializing with empty toolset") + logger.warning("No config found, initializing with empty toolset") return await self._setup_empty_toolset() self._config_loader.detect_server_types(config) diff --git a/tests/agents/__init__.py b/tests/agents/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/agents/test_config.py b/tests/agents/test_config.py new file mode 100644 index 00000000..9b0e4365 --- /dev/null +++ b/tests/agents/test_config.py @@ -0,0 +1,90 @@ +# tests/agents/test_config.py +"""Unit tests for AgentConfig.""" + +from __future__ import annotations + +import pytest + +from mcp_cli.agents.config import AgentConfig + + +class TestAgentConfigDefaults: + def test_minimal(self): + cfg = AgentConfig(agent_id="a1") + assert cfg.agent_id == "a1" + assert cfg.name == "" + assert cfg.role == "" + assert cfg.model is None + assert cfg.provider is None + assert cfg.system_prompt is None + assert cfg.allowed_tools is None + assert cfg.denied_tools is None + assert cfg.allowed_servers is None + assert cfg.tool_timeout_override is None + assert cfg.auto_approve_tools is None + assert cfg.parent_agent_id is None + assert cfg.initial_prompt == "" + + def test_full(self): + cfg = AgentConfig( + agent_id="agent-research", + name="Researcher", + role="research", + model="gpt-4o", + provider="openai", + system_prompt="You are a research assistant.", + allowed_tools=["web_search", "read_file"], + denied_tools=["write_file"], + allowed_servers=["server-1"], + tool_timeout_override=30.0, + auto_approve_tools=["web_search"], + parent_agent_id="agent-main", + initial_prompt="Find the MCP spec.", + ) + assert cfg.name == "Researcher" + assert cfg.role == "research" + assert cfg.model == "gpt-4o" + assert cfg.provider == "openai" + assert cfg.allowed_tools == ["web_search", "read_file"] + assert cfg.denied_tools == ["write_file"] + assert cfg.parent_agent_id == "agent-main" + assert cfg.initial_prompt == "Find the MCP spec." + + +class TestAgentConfigSerialization: + def test_dict_roundtrip(self): + cfg = AgentConfig(agent_id="x", name="X", role="worker", model="gpt-4") + d = cfg.model_dump() + assert d["agent_id"] == "x" + assert d["role"] == "worker" + cfg2 = AgentConfig(**d) + assert cfg2 == cfg + + def test_json_roundtrip(self): + cfg = AgentConfig( + agent_id="a", + name="A", + allowed_tools=["t1", "t2"], + ) + json_str = cfg.model_dump_json() + cfg2 = AgentConfig.model_validate_json(json_str) + assert cfg2 == cfg + + +class TestAgentConfigImport: + def test_importable_from_package(self): + from mcp_cli.agents import AgentConfig as AC + + assert AC is AgentConfig + + +class TestAgentConfigValidation: + def test_agent_id_required(self): + with pytest.raises(Exception): + AgentConfig() # type: ignore[call-arg] + + def test_extra_fields_ignored(self): + """Extra fields are silently ignored by Pydantic v2 default.""" + cfg = AgentConfig(agent_id="x", unknown_field="ignored") # type: ignore[call-arg] + assert cfg.agent_id == "x" + assert not hasattr(cfg, "unknown_field") diff --git a/tests/agents/test_group_store.py b/tests/agents/test_group_store.py new file mode 100644 index 00000000..218071fd --- /dev/null +++ b/tests/agents/test_group_store.py @@ -0,0 +1,171 @@ +# tests/agents/test_group_store.py +"""Unit tests for group save/restore.""" + +from __future__ import annotations + +import json +from unittest.mock import MagicMock + +import pytest + +from mcp_cli.agents.config import AgentConfig +from mcp_cli.agents.group_store import ( + list_groups, + load_group_manifest, + save_group, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_agent_manager(*agent_configs): + """Create a mock AgentManager with the given agent configs.""" + mgr = MagicMock() + snapshots = {} + statuses = [] + + for cfg in agent_configs: + ctx = MagicMock() + ctx.agent_id = cfg.agent_id + ctx.session_id = f"session-{cfg.agent_id}" + ctx.conversation_history = [] + snapshots[cfg.agent_id] = {"config": cfg, "context": ctx} + statuses.append( + { + "agent_id": cfg.agent_id, + "name": cfg.name, + "role": cfg.role, + "status": "active", + } + ) + + mgr.list_agents.return_value = statuses + mgr.get_agent_snapshot.side_effect = lambda aid: snapshots.get(aid) + return mgr + + +# --------------------------------------------------------------------------- +# TestSaveGroup +# --------------------------------------------------------------------------- + + +class TestSaveGroup: + @pytest.mark.asyncio + async def test_save_empty_group(self, tmp_path): + mgr = _make_agent_manager() + result = await save_group(mgr, description="empty", group_dir=tmp_path) + assert result == tmp_path + manifest = json.loads((tmp_path / "group.json").read_text()) + assert manifest["description"] == "empty" + assert manifest["agents"] == [] + + @pytest.mark.asyncio + async def test_save_with_agents(self, tmp_path): + cfg_a = AgentConfig(agent_id="a", name="Agent A", role="worker") + cfg_b = AgentConfig(agent_id="b", name="Agent B", role="researcher") + mgr = _make_agent_manager(cfg_a, cfg_b) + + result = await save_group(mgr, description="test group", group_dir=tmp_path) + manifest = json.loads((result / "group.json").read_text()) + assert len(manifest["agents"]) == 2 + agent_ids = {a["agent_id"] for a in manifest["agents"]} + assert agent_ids == {"a", "b"} + + # Check session files exist + assert (tmp_path / "a" / "session.json").exists() + assert (tmp_path / "b" / "session.json").exists() + + @pytest.mark.asyncio + async def test_save_preserves_config_fields(self, tmp_path): + cfg = AgentConfig( + agent_id="x", + name="X", + role="coder", + model="gpt-4", + provider="openai", + parent_agent_id="main", + ) + mgr = _make_agent_manager(cfg) + await save_group(mgr, group_dir=tmp_path) + + manifest = json.loads((tmp_path / "group.json").read_text()) + agent = manifest["agents"][0] + assert agent["agent_id"] == "x" + assert agent["role"] == "coder" + assert agent["model"] == "gpt-4" + assert agent["parent_agent_id"] == "main" + + +# --------------------------------------------------------------------------- +# TestLoadGroupManifest +# --------------------------------------------------------------------------- + + +class TestLoadGroupManifest: + def test_load_valid(self, tmp_path): + data = { + "group_id": "g1", + "created_at": "2026-01-01T00:00:00Z", + "description": "test", + "agents": [], + } + (tmp_path / "group.json").write_text(json.dumps(data)) + manifest = load_group_manifest(tmp_path) + assert manifest["group_id"] == "g1" + assert manifest["description"] == "test" + + def test_load_missing_raises(self, tmp_path): + with pytest.raises(FileNotFoundError): + load_group_manifest(tmp_path) + + +# --------------------------------------------------------------------------- +# TestListGroups +# --------------------------------------------------------------------------- + + +class TestListGroups: + def test_empty_dir(self, tmp_path): + assert list_groups(tmp_path) == [] + + def test_nonexistent_dir(self, tmp_path): + assert list_groups(tmp_path / "nope") == [] + + def test_lists_groups(self, tmp_path): + # Create two groups + for name in ["group-1", "group-2"]: + d = tmp_path / name + d.mkdir() + data = { + "group_id": name, + "description": f"desc-{name}", + "created_at": "2026-01-01", + "agents": [{"agent_id": "a"}], + } + (d / "group.json").write_text(json.dumps(data)) + + groups = list_groups(tmp_path) + assert len(groups) == 2 + ids = {g["group_id"] for g in groups} + assert ids == {"group-1", "group-2"} + for g in groups: + assert g["agent_count"] == 1 + + def test_skips_invalid(self, tmp_path): + # Create one valid and one invalid group + valid = tmp_path / "valid" + valid.mkdir() + (valid / "group.json").write_text( + json.dumps({"group_id": "valid", "agents": []}) + ) + + invalid = tmp_path / "invalid" + invalid.mkdir() + (invalid / "group.json").write_text("not json") + + groups = list_groups(tmp_path) + assert len(groups) == 1 + assert groups[0]["group_id"] == "valid" diff --git a/tests/agents/test_loop.py b/tests/agents/test_loop.py new file mode 100644 index 00000000..c8e85ac8 --- /dev/null +++ b/tests/agents/test_loop.py @@ -0,0 +1,254 @@ +# tests/agents/test_loop.py +"""Unit tests for the headless agent loop.""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from mcp_cli.agents.loop import run_agent_loop + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_context(agent_id: str = "test-agent"): + """Return a mock ChatContext.""" + ctx = MagicMock() + ctx.agent_id = agent_id + ctx.exit_requested = False + ctx.conversation_history = [] + ctx.dashboard_bridge = None + ctx.add_user_message = AsyncMock() + return ctx + + +def _make_ui(agent_id: str = "test-agent"): + from mcp_cli.agents.headless_ui import HeadlessUIManager + + return HeadlessUIManager(agent_id=agent_id) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestRunAgentLoop: + @pytest.mark.asyncio + async def test_exit_on_stop_signal(self): + """Loop exits on '__stop__' message.""" + ctx = _make_context() + ui = _make_ui() + q: asyncio.Queue = asyncio.Queue() + done = asyncio.Event() + + await q.put("__stop__") + + with patch("mcp_cli.chat.conversation.ConversationProcessor"): + result = await run_agent_loop(ctx, ui, q, done) + + assert done.is_set() + assert isinstance(result, str) + + @pytest.mark.asyncio + async def test_exit_on_quit(self): + """Loop exits on 'quit' message.""" + ctx = _make_context() + ui = _make_ui() + q: asyncio.Queue = asyncio.Queue() + done = asyncio.Event() + + await q.put("quit") + + with patch("mcp_cli.chat.conversation.ConversationProcessor"): + await run_agent_loop(ctx, ui, q, done) + + assert done.is_set() + + @pytest.mark.asyncio + async def test_skip_empty_and_none(self): + """Loop skips None and empty messages.""" + ctx = _make_context() + ui = _make_ui() + q: asyncio.Queue = asyncio.Queue() + done = asyncio.Event() + + await q.put(None) + await q.put("") + await q.put(" ") + await q.put("exit") + + with patch("mcp_cli.chat.conversation.ConversationProcessor"): + await run_agent_loop(ctx, ui, q, done) + + assert done.is_set() + ctx.add_user_message.assert_not_called() + + @pytest.mark.asyncio + async def test_processes_prompt(self): + """Loop processes a normal prompt via ConversationProcessor.""" + ctx = _make_context() + ui = _make_ui() + q: asyncio.Queue = asyncio.Queue() + done = asyncio.Event() + + await q.put("Hello agent") + await q.put("exit") + + mock_convo = MagicMock() + mock_convo.process_conversation = AsyncMock() + + with patch( + "mcp_cli.chat.conversation.ConversationProcessor", + return_value=mock_convo, + ): + await run_agent_loop(ctx, ui, q, done) + + ctx.add_user_message.assert_called_once_with("Hello agent") + mock_convo.process_conversation.assert_called_once() + assert done.is_set() + + @pytest.mark.asyncio + async def test_captures_last_response(self): + """Loop captures the last assistant response from history.""" + ctx = _make_context() + ctx.conversation_history = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "Hello there!"}, + ] + ui = _make_ui() + q: asyncio.Queue = asyncio.Queue() + done = asyncio.Event() + + await q.put("hi") + await q.put("exit") + + mock_convo = MagicMock() + mock_convo.process_conversation = AsyncMock() + + with patch( + "mcp_cli.chat.conversation.ConversationProcessor", + return_value=mock_convo, + ): + result = await run_agent_loop(ctx, ui, q, done) + + assert "Hello there!" in result + + @pytest.mark.asyncio + async def test_handles_cancellation(self): + """Loop handles CancelledError gracefully.""" + ctx = _make_context() + ui = _make_ui() + q: asyncio.Queue = asyncio.Queue() + done = asyncio.Event() + + mock_convo = MagicMock() + mock_convo.process_conversation = AsyncMock( + side_effect=asyncio.CancelledError() + ) + + await q.put("do something") + + with patch( + "mcp_cli.chat.conversation.ConversationProcessor", + return_value=mock_convo, + ): + await run_agent_loop(ctx, ui, q, done) + + assert done.is_set() + + @pytest.mark.asyncio + async def test_handles_exception(self): + """Loop handles unexpected exceptions.""" + ctx = _make_context() + ui = _make_ui() + q: asyncio.Queue = asyncio.Queue() + done = asyncio.Event() + + mock_convo = MagicMock() + mock_convo.process_conversation = AsyncMock(side_effect=RuntimeError("boom")) + + await q.put("do something") + + with patch( + "mcp_cli.chat.conversation.ConversationProcessor", + return_value=mock_convo, + ): + result = await run_agent_loop(ctx, ui, q, done) + + assert done.is_set() + assert "Error" in result + + @pytest.mark.asyncio + async def test_exit_requested_stops_loop(self): + """Loop exits when context.exit_requested is True.""" + ctx = _make_context() + ctx.exit_requested = True + ui = _make_ui() + q: asyncio.Queue = asyncio.Queue() + done = asyncio.Event() + + with patch("mcp_cli.chat.conversation.ConversationProcessor"): + result = await run_agent_loop(ctx, ui, q, done) + + assert done.is_set() + assert result == "Agent completed." + + @pytest.mark.asyncio + async def test_dashboard_bridge_wired(self): + """Loop wires dashboard bridge input queue and broadcasts messages.""" + ctx = _make_context() + bridge = MagicMock() + bridge.set_input_queue = MagicMock() + bridge.on_message = AsyncMock() + ctx.dashboard_bridge = bridge + + ui = _make_ui() + q: asyncio.Queue = asyncio.Queue() + done = asyncio.Event() + + await q.put("hello") + await q.put("exit") + + mock_convo = MagicMock() + mock_convo.process_conversation = AsyncMock() + + with patch( + "mcp_cli.chat.conversation.ConversationProcessor", + return_value=mock_convo, + ): + await run_agent_loop(ctx, ui, q, done) + + bridge.set_input_queue.assert_called_once_with(q) + bridge.on_message.assert_called_once_with("user", "hello") + + @pytest.mark.asyncio + async def test_max_response_length_truncated(self): + """Loop truncates last_response to 500 chars.""" + ctx = _make_context() + long_response = "x" * 1000 + ctx.conversation_history = [ + {"role": "assistant", "content": long_response}, + ] + ui = _make_ui() + q: asyncio.Queue = asyncio.Queue() + done = asyncio.Event() + + await q.put("go") + await q.put("exit") + + mock_convo = MagicMock() + mock_convo.process_conversation = AsyncMock() + + with patch( + "mcp_cli.chat.conversation.ConversationProcessor", + return_value=mock_convo, + ): + result = await run_agent_loop(ctx, ui, q, done) + + assert len(result) == 500 diff --git a/tests/agents/test_manager.py b/tests/agents/test_manager.py new file mode 100644 index 00000000..19f74e22 --- /dev/null +++ b/tests/agents/test_manager.py @@ -0,0 +1,323 @@ +# tests/agents/test_manager.py +"""Unit tests for AgentManager.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from mcp_cli.agents.config import AgentConfig +from mcp_cli.agents.headless_ui import HeadlessUIManager +from mcp_cli.agents.manager import MAX_AGENTS, AgentManager + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_router(): + from mcp_cli.dashboard.router import AgentRouter + + server = MagicMock() + server.broadcast = AsyncMock() + server.send_to_client = AsyncMock() + server.has_clients = False + server.on_browser_message = None + server.on_client_connected = None + server.on_client_disconnected = None + return AgentRouter(server) + + +def _make_tool_manager(): + tm = MagicMock() + tm.list_tools = MagicMock(return_value=[]) + tm.execute_tool = AsyncMock() + tm.close = AsyncMock() + return tm + + +def _make_model_manager(): + mm = MagicMock() + mm.active_provider = "test" + mm.active_model = "test-model" + return mm + + +# --------------------------------------------------------------------------- +# TestHeadlessUIManager +# --------------------------------------------------------------------------- + + +class TestHeadlessUIManager: + def test_defaults(self): + ui = HeadlessUIManager(agent_id="a1") + assert ui.agent_id == "a1" + assert ui.verbose_mode is False + assert ui.is_streaming_response is False + + @pytest.mark.asyncio + async def test_auto_approve(self): + ui = HeadlessUIManager() + assert await ui.do_confirm_tool_execution("tool", {}) is True + + @pytest.mark.asyncio + async def test_start_stop_streaming(self): + ui = HeadlessUIManager() + await ui.start_streaming_response() + await ui.stop_streaming_response() + assert ui.streaming_handler is None + + @pytest.mark.asyncio + async def test_tool_lifecycle(self): + ui = HeadlessUIManager() + ui.print_tool_call("test_tool", {"a": 1}) + await ui.start_tool_execution("test_tool", {"a": 1}) + await ui.finish_tool_execution(result="ok", success=True) + await ui.finish_tool_calls() + + @pytest.mark.asyncio + async def test_cleanup(self): + ui = HeadlessUIManager() + await ui.cleanup() + + +# --------------------------------------------------------------------------- +# TestAgentManagerBasic +# --------------------------------------------------------------------------- + + +class TestAgentManagerBasic: + def test_init(self): + tm = _make_tool_manager() + router = _make_router() + mgr = AgentManager(tm, router) + assert mgr.list_agents() == [] + assert mgr.list_artifacts() == [] + + @pytest.mark.asyncio + async def test_spawn_duplicate_raises(self): + tm = _make_tool_manager() + router = _make_router() + mgr = AgentManager(tm, router) + + # Mock spawn internals — patch at the source modules since + # manager.py uses lazy imports inside spawn_agent() + mock_ctx = MagicMock() + mock_ctx.agent_id = "a" + mock_ctx.initialize = AsyncMock(return_value=True) + mock_ctx.openai_tools = [] + mock_ctx.conversation_history = [] + mock_ctx.exit_requested = True # stop loop immediately + mock_ctx.dashboard_bridge = None + mock_ctx._system_prompt = "" + mock_ctx._system_prompt_dirty = False + + with ( + patch( + "mcp_cli.chat.chat_context.ChatContext.create", + return_value=mock_ctx, + ), + patch("mcp_cli.dashboard.bridge.DashboardBridge") as MockBridge, + ): + mock_bridge = MagicMock() + mock_bridge.set_context = MagicMock() + mock_bridge.set_input_queue = MagicMock() + MockBridge.return_value = mock_bridge + + await mgr.spawn_agent(AgentConfig(agent_id="a", name="A")) + with pytest.raises(ValueError, match="already exists"): + await mgr.spawn_agent(AgentConfig(agent_id="a", name="A2")) + + await mgr.stop_all() + + @pytest.mark.asyncio + async def test_stop_nonexistent(self): + mgr = AgentManager(_make_tool_manager(), _make_router()) + assert await mgr.stop_agent("no-such") is False + + +# --------------------------------------------------------------------------- +# TestAgentManagerArtifacts +# --------------------------------------------------------------------------- + + +class TestAgentManagerArtifacts: + def test_publish_and_get(self): + mgr = AgentManager(_make_tool_manager(), _make_router()) + mgr.publish_artifact("agent-a", "results", {"data": [1, 2, 3]}) + assert mgr.get_artifact("results") == {"data": [1, 2, 3]} + + def test_get_nonexistent(self): + mgr = AgentManager(_make_tool_manager(), _make_router()) + assert mgr.get_artifact("nope") is None + + def test_list_artifacts(self): + mgr = AgentManager(_make_tool_manager(), _make_router()) + mgr.publish_artifact("a", "art1", "content1") + mgr.publish_artifact("b", "art2", "content2") + arts = mgr.list_artifacts() + assert len(arts) == 2 + ids = {a["artifact_id"] for a in arts} + assert ids == {"art1", "art2"} + + +# --------------------------------------------------------------------------- +# TestAgentManagerMessaging +# --------------------------------------------------------------------------- + + +class TestAgentManagerMessaging: + @pytest.mark.asyncio + async def test_send_to_nonexistent_returns_false(self): + mgr = AgentManager(_make_tool_manager(), _make_router()) + assert await mgr.send_message("a", "b", "hello") is False + + @pytest.mark.asyncio + async def test_get_messages_empty(self): + mgr = AgentManager(_make_tool_manager(), _make_router()) + assert await mgr.get_messages("nonexistent") == [] + + +# --------------------------------------------------------------------------- +# TestAgentManagerStatus +# --------------------------------------------------------------------------- + + +class TestAgentManagerStatus: + def test_get_status_nonexistent(self): + mgr = AgentManager(_make_tool_manager(), _make_router()) + assert mgr.get_agent_status("nope") is None + + +# --------------------------------------------------------------------------- +# TestAgentManagerMaxAgents +# --------------------------------------------------------------------------- + + +class TestAgentManagerMaxAgents: + def test_max_agents_constant(self): + assert MAX_AGENTS == 10 + + +# --------------------------------------------------------------------------- +# Helper: spawn a mock agent into the manager +# --------------------------------------------------------------------------- + + +async def _spawn_mock_agent(mgr, agent_id="a", name="A", **kwargs): + """Spawn a mock agent, returns the manager.""" + mock_ctx = MagicMock() + mock_ctx.agent_id = agent_id + mock_ctx.initialize = AsyncMock(return_value=True) + mock_ctx.openai_tools = [] + mock_ctx.conversation_history = [] + mock_ctx.exit_requested = True # stop loop immediately + mock_ctx.dashboard_bridge = None + mock_ctx._system_prompt = "" + mock_ctx._system_prompt_dirty = False + + with ( + patch( + "mcp_cli.chat.chat_context.ChatContext.create", + return_value=mock_ctx, + ), + patch("mcp_cli.dashboard.bridge.DashboardBridge") as MockBridge, + ): + mock_bridge = MagicMock() + mock_bridge.set_context = MagicMock() + mock_bridge.set_input_queue = MagicMock() + MockBridge.return_value = mock_bridge + + cfg = AgentConfig(agent_id=agent_id, name=name, **kwargs) + await mgr.spawn_agent(cfg) + + return mgr + + +# --------------------------------------------------------------------------- +# TestAgentManagerLifecycle (with spawned agent) +# --------------------------------------------------------------------------- + + +class TestAgentManagerLifecycle: + @pytest.mark.asyncio + async def test_spawn_and_list(self): + mgr = AgentManager(_make_tool_manager(), _make_router()) + await _spawn_mock_agent(mgr, "x", "Agent X", role="worker") + agents = mgr.list_agents() + assert len(agents) == 1 + assert agents[0]["agent_id"] == "x" + assert agents[0]["name"] == "Agent X" + await mgr.stop_all() + + @pytest.mark.asyncio + async def test_stop_agent_returns_true(self): + mgr = AgentManager(_make_tool_manager(), _make_router()) + await _spawn_mock_agent(mgr) + assert await mgr.stop_agent("a") is True + assert mgr.list_agents() == [] + + @pytest.mark.asyncio + async def test_get_agent_snapshot(self): + mgr = AgentManager(_make_tool_manager(), _make_router()) + await _spawn_mock_agent(mgr, "s1", "Snap Agent") + snap = mgr.get_agent_snapshot("s1") + assert snap is not None + assert snap["config"].agent_id == "s1" + assert snap["context"] is not None + await mgr.stop_all() + + @pytest.mark.asyncio + async def test_get_agent_snapshot_nonexistent(self): + mgr = AgentManager(_make_tool_manager(), _make_router()) + assert mgr.get_agent_snapshot("nope") is None + + +# --------------------------------------------------------------------------- +# TestAgentManagerMessagingWithAgent +# --------------------------------------------------------------------------- + + +class TestAgentManagerMessagingWithAgent: + @pytest.mark.asyncio + async def test_send_message_injects_into_queue(self): + mgr = AgentManager(_make_tool_manager(), _make_router()) + await _spawn_mock_agent(mgr, "target", "Target") + + result = await mgr.send_message("sender", "target", "hello world") + assert result is True + + # Verify the message is in the agent's input queue + handle = mgr._agents["target"] + msg = handle.input_queue.get_nowait() + assert "[Message from sender]" in msg + assert "hello world" in msg + await mgr.stop_all() + + +# --------------------------------------------------------------------------- +# TestAgentManagerWait +# --------------------------------------------------------------------------- + + +class TestAgentManagerWait: + @pytest.mark.asyncio + async def test_wait_unknown_returns_error(self): + mgr = AgentManager(_make_tool_manager(), _make_router()) + result = await mgr.wait_agent("nonexistent") + assert "error" in result + + @pytest.mark.asyncio + async def test_wait_timeout(self): + mgr = AgentManager(_make_tool_manager(), _make_router()) + await _spawn_mock_agent(mgr, "w1", "Waiter") + # The done_event is not set by mock, so wait should timeout + # But the agent loop exits immediately (exit_requested=True), + # so let's just test with a very short timeout + # The done_event might already be set by the loop finishing + result = await mgr.wait_agent("w1", timeout=0.01) + assert result["agent_id"] == "w1" + assert result["status"] in ("completed", "timeout") + await mgr.stop_all() diff --git a/tests/agents/test_tools.py b/tests/agents/test_tools.py new file mode 100644 index 00000000..e9074bc6 --- /dev/null +++ b/tests/agents/test_tools.py @@ -0,0 +1,166 @@ +# tests/agents/test_tools.py +"""Unit tests for agent orchestration tool definitions and handler.""" + +from __future__ import annotations + +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from mcp_cli.agents.tools import ( + _AGENT_TOOL_NAMES, + get_agent_tools_as_dicts, + handle_agent_tool, +) + + +# --------------------------------------------------------------------------- +# TestToolDefinitions +# --------------------------------------------------------------------------- + + +class TestToolDefinitions: + def test_tool_count(self): + tools = get_agent_tools_as_dicts() + assert len(tools) == 6 + + def test_tool_names_match_frozenset(self): + tools = get_agent_tools_as_dicts() + names = {t["function"]["name"] for t in tools} + assert names == _AGENT_TOOL_NAMES + + def test_all_have_function_key(self): + tools = get_agent_tools_as_dicts() + for t in tools: + assert t["type"] == "function" + assert "name" in t["function"] + assert "description" in t["function"] + assert "parameters" in t["function"] + + def test_spawn_has_required_fields(self): + tools = get_agent_tools_as_dicts() + spawn = next(t for t in tools if t["function"]["name"] == "agent_spawn") + required = spawn["function"]["parameters"]["required"] + assert "name" in required + assert "initial_prompt" in required + + +# --------------------------------------------------------------------------- +# TestHandleAgentTool +# --------------------------------------------------------------------------- + + +class TestHandleAgentTool: + @pytest.mark.asyncio + async def test_unknown_tool(self): + mgr = MagicMock() + result = await handle_agent_tool("unknown_tool", {}, mgr) + data = json.loads(result) + assert "error" in data + + @pytest.mark.asyncio + async def test_agent_stop(self): + mgr = MagicMock() + mgr.stop_agent = AsyncMock(return_value=True) + result = await handle_agent_tool("agent_stop", {"agent_id": "a1"}, mgr) + data = json.loads(result) + assert data["success"] is True + mgr.stop_agent.assert_awaited_once_with("a1") + + @pytest.mark.asyncio + async def test_agent_stop_missing_id(self): + mgr = MagicMock() + result = await handle_agent_tool("agent_stop", {}, mgr) + data = json.loads(result) + assert "error" in data + + @pytest.mark.asyncio + async def test_agent_message(self): + mgr = MagicMock() + mgr.send_message = AsyncMock(return_value=True) + result = await handle_agent_tool( + "agent_message", + {"agent_id": "a1", "content": "hello"}, + mgr, + caller_agent_id="supervisor", + ) + data = json.loads(result) + assert data["success"] is True + mgr.send_message.assert_awaited_once_with("supervisor", "a1", "hello") + + @pytest.mark.asyncio + async def test_agent_message_missing_fields(self): + mgr = MagicMock() + result = await handle_agent_tool("agent_message", {}, mgr) + data = json.loads(result) + assert "error" in data + + @pytest.mark.asyncio + async def test_agent_wait(self): + mgr = MagicMock() + mgr.wait_agent = AsyncMock( + return_value={"agent_id": "a1", "status": "completed", "summary": "done"} + ) + result = await handle_agent_tool("agent_wait", {"agent_id": "a1"}, mgr) + data = json.loads(result) + assert data["status"] == "completed" + + @pytest.mark.asyncio + async def test_agent_status(self): + mgr = MagicMock() + mgr.get_agent_status.return_value = { + "agent_id": "a1", + "status": "active", + "name": "Test", + } + result = await handle_agent_tool("agent_status", {"agent_id": "a1"}, mgr) + data = json.loads(result) + assert data["status"] == "active" + + @pytest.mark.asyncio + async def test_agent_status_unknown(self): + mgr = MagicMock() + mgr.get_agent_status.return_value = None + result = await handle_agent_tool("agent_status", {"agent_id": "nope"}, mgr) + data = json.loads(result) + assert "error" in data + + @pytest.mark.asyncio + async def test_agent_list(self): + mgr = MagicMock() + mgr.list_agents.return_value = [ + {"agent_id": "a", "status": "active"}, + {"agent_id": "b", "status": "completed"}, + ] + result = await handle_agent_tool("agent_list", {}, mgr) + data = json.loads(result) + assert len(data["agents"]) == 2 + + @pytest.mark.asyncio + async def test_agent_spawn(self): + mgr = MagicMock() + mgr.spawn_agent = AsyncMock(return_value="agent-research") + result = await handle_agent_tool( + "agent_spawn", + {"name": "Research", "initial_prompt": "Find docs"}, + mgr, + caller_agent_id="main", + ) + data = json.loads(result) + assert data["success"] is True + assert data["agent_id"] == "agent-research" + # Verify spawn was called with an AgentConfig + mgr.spawn_agent.assert_awaited_once() + config = mgr.spawn_agent.call_args[0][0] + assert config.name == "Research" + assert config.parent_agent_id == "main" + + @pytest.mark.asyncio + async def test_handler_catches_exceptions(self): + mgr = MagicMock() + mgr.stop_agent = AsyncMock(side_effect=RuntimeError("boom")) + result = await handle_agent_tool("agent_stop", {"agent_id": "a1"}, mgr) + data = json.loads(result) + assert "error" in data + assert "boom" in data["error"] diff --git a/tests/chat/test_agent_tool_state.py b/tests/chat/test_agent_tool_state.py new file mode 100644 index 00000000..cec7e1bc --- /dev/null +++ b/tests/chat/test_agent_tool_state.py @@ -0,0 +1,76 @@ +# tests/chat/test_agent_tool_state.py +"""Unit tests for per-agent tool state isolation.""" + +from __future__ import annotations + +from mcp_cli.chat.agent_tool_state import ( + _reset_registry, + get_agent_tool_state, + remove_agent_tool_state, +) + + +class TestAgentToolStateRegistry: + """Tests for the agent_tool_state registry.""" + + def setup_method(self): + _reset_registry() + + def teardown_method(self): + _reset_registry() + + def test_default_returns_global_singleton(self): + """'default' agent_id delegates to the upstream singleton.""" + from chuk_ai_session_manager.guards import get_tool_state + + global_ts = get_tool_state() + default_ts = get_agent_tool_state("default") + assert default_ts is global_ts + + def test_non_default_returns_new_instance(self): + """Non-default agent_id creates a fresh ToolStateManager.""" + from chuk_ai_session_manager.guards import get_tool_state + + agent_ts = get_agent_tool_state("agent-research") + global_ts = get_tool_state() + assert agent_ts is not global_ts + + def test_same_agent_id_returns_same_instance(self): + """Repeated calls with the same agent_id return the same instance.""" + ts1 = get_agent_tool_state("agent-a") + ts2 = get_agent_tool_state("agent-a") + assert ts1 is ts2 + + def test_different_agent_ids_return_different_instances(self): + """Different agent_ids get independent instances.""" + ts_a = get_agent_tool_state("agent-a") + ts_b = get_agent_tool_state("agent-b") + assert ts_a is not ts_b + + def test_remove_agent_tool_state(self): + """Removing tool state means next access creates a fresh instance.""" + ts1 = get_agent_tool_state("agent-a") + remove_agent_tool_state("agent-a") + ts2 = get_agent_tool_state("agent-a") + assert ts1 is not ts2 + + def test_remove_nonexistent_is_noop(self): + """Removing a non-existent agent_id doesn't raise.""" + remove_agent_tool_state("no-such-agent") # should not raise + + def test_reset_registry_clears_all(self): + """_reset_registry clears all per-agent instances.""" + get_agent_tool_state("agent-a") + get_agent_tool_state("agent-b") + _reset_registry() + # After reset, new calls should create fresh instances + ts = get_agent_tool_state("agent-a") + assert ts is not None # it exists but is fresh + + def test_default_not_in_registry(self): + """The 'default' agent's state is NOT stored in the internal registry.""" + get_agent_tool_state("default") + _reset_registry() # only clears internal registry + # default should still work (delegates to global singleton) + ts = get_agent_tool_state("default") + assert ts is not None diff --git a/tests/chat/test_chat_context.py b/tests/chat/test_chat_context.py index 0827b254..9c203759 100644 --- a/tests/chat/test_chat_context.py +++ b/tests/chat/test_chat_context.py @@ -1918,7 +1918,9 @@ async def test_save_session_with_token_usage(self, monkeypatch, tmp_path): # Verify the saved file has token_usage import json - saved = json.loads(tmp_path.joinpath(f"{ctx.session_id}.json").read_text()) + saved = json.loads( + tmp_path.joinpath("default", f"{ctx.session_id}.json").read_text() + ) assert saved.get("token_usage") is not None @pytest.mark.asyncio @@ -2140,7 +2142,7 @@ async def test_auto_save_check_below_threshold(self, monkeypatch, tmp_path): ctx.auto_save_check() # Nothing saved yet - assert list(tmp_path.glob("*.json")) == [] + assert list(tmp_path.glob("**/*.json")) == [] @pytest.mark.asyncio async def test_auto_save_check_triggers_save(self, monkeypatch, tmp_path): @@ -2157,8 +2159,8 @@ async def test_auto_save_check_triggers_save(self, monkeypatch, tmp_path): for _ in range(DEFAULT_AUTO_SAVE_INTERVAL): ctx.auto_save_check() - # Should have saved - saved_files = list(tmp_path.glob("*.json")) + # Should have saved (files are in tmp_path/default/) + saved_files = list(tmp_path.glob("**/*.json")) assert len(saved_files) == 1 # Counter should be reset to 0 assert ctx._auto_save_counter == 0 @@ -2665,3 +2667,60 @@ async def test_conversation_history_mixed_events(self, monkeypatch): assert "user" in roles assert "assistant" in roles assert "tool" in roles + + +# --------------------------------------------------------------------------- +# agent_id plumbing +# --------------------------------------------------------------------------- + + +class TestAgentId: + """Verify agent_id is stored, propagated, and serialized.""" + + def test_agent_id_default(self, monkeypatch): + ctx = _make_initialized_ctx(monkeypatch) + assert ctx.agent_id == "default" + + def test_agent_id_custom(self, monkeypatch): + ctx = _make_initialized_ctx(monkeypatch, agent_id="my-agent") + assert ctx.agent_id == "my-agent" + + def test_to_dict_includes_agent_id(self, monkeypatch): + ctx = _make_initialized_ctx(monkeypatch, agent_id="export-agent") + d = ctx.to_dict() + assert d["agent_id"] == "export-agent" + + def test_save_session_writes_agent_id(self, monkeypatch, tmp_path): + ctx = _make_initialized_ctx(monkeypatch, agent_id="save-agent") + # Point session store to tmp_path + from mcp_cli.chat.session_store import SessionStore + + ctx._session_store = SessionStore(sessions_dir=tmp_path, agent_id="save-agent") + ctx._system_prompt = "SYS" + + path = ctx.save_session() + assert path is not None + + loaded = ctx._session_store.load(ctx.session_id) + assert loaded is not None + assert loaded.metadata.agent_id == "save-agent" + + def test_create_forwards_agent_id(self, monkeypatch): + monkeypatch.setattr( + "mcp_cli.chat.chat_context.generate_system_prompt", + lambda tools=None, **kw: "SYS_PROMPT", + ) + from unittest.mock import Mock + from mcp_cli.model_management import ModelManager + + mock_mm = Mock(spec=ModelManager) + mock_mm.get_client.return_value = None + mock_mm.get_active_provider.return_value = "mock" + mock_mm.get_active_model.return_value = "mock-model" + + ctx = ChatContext.create( + tool_manager=DummyToolManager(), + model_manager=mock_mm, + agent_id="factory-agent", + ) + assert ctx.agent_id == "factory-agent" diff --git a/tests/chat/test_chat_handler_coverage.py b/tests/chat/test_chat_handler_coverage.py index 71e30c45..46bdbce0 100644 --- a/tests/chat/test_chat_handler_coverage.py +++ b/tests/chat/test_chat_handler_coverage.py @@ -770,3 +770,182 @@ async def test_interrupt_nothing(self): result = await handle_interrupt_command(ui) assert result is True mock_output.info.assert_called() + + +# =========================================================================== +# Tests for multi-agent wiring +# =========================================================================== + + +class TestMultiAgentWiring: + """Tests for multi_agent=True flag in handle_chat_mode.""" + + @pytest.mark.asyncio + async def test_multi_agent_creates_agent_manager(self): + """multi_agent=True creates AgentManager and sets it on context.""" + tool_mgr = MagicMock() + tool_mgr.close = AsyncMock() + tool_mgr.get_tool_count = MagicMock(return_value=5) + + mock_ctx = _make_ctx() + mock_ctx.initialize = AsyncMock(return_value=True) + mock_ctx.agent_id = "default" + mock_ctx.dashboard_bridge = None + mock_ctx.conversation_history = [] + mock_ctx.save_session = MagicMock(return_value=None) + + mock_app_ctx = MagicMock() + mock_app_ctx.model_manager = MagicMock() + mock_app_ctx.initialize = AsyncMock() + + ui = _make_ui() + convo = _make_convo() + + mock_bridge = MagicMock() + mock_bridge.set_context = MagicMock() + mock_bridge.set_tool_call_callback = MagicMock() + mock_bridge.set_input_queue = MagicMock() + mock_bridge.on_shutdown = AsyncMock() + mock_bridge.server = MagicMock() + mock_bridge.server.stop = AsyncMock() + + mock_launch = AsyncMock(return_value=(MagicMock(), MagicMock(), 9120)) + mock_agent_manager = MagicMock() + mock_agent_manager.stop_all = AsyncMock() + + with ( + patch("mcp_cli.chat.chat_handler.initialize_config"), + patch( + "mcp_cli.chat.chat_handler.initialize_context", + return_value=mock_app_ctx, + ), + patch("mcp_cli.chat.chat_handler.output"), + patch("mcp_cli.chat.chat_handler.clear_screen"), + patch("mcp_cli.chat.chat_handler.display_chat_banner"), + patch("mcp_cli.chat.chat_handler.ChatContext") as MockCC, + patch("mcp_cli.chat.chat_handler.ChatUIManager", return_value=ui), + patch( + "mcp_cli.chat.chat_handler.ConversationProcessor", + return_value=convo, + ), + patch( + "mcp_cli.chat.chat_handler._run_enhanced_chat_loop", + new_callable=AsyncMock, + ), + patch( + "mcp_cli.chat.chat_handler._safe_cleanup", + new_callable=AsyncMock, + ), + patch( + "mcp_cli.dashboard.launcher.launch_dashboard", + mock_launch, + ), + patch( + "mcp_cli.dashboard.bridge.DashboardBridge", + return_value=mock_bridge, + ), + patch( + "mcp_cli.agents.manager.AgentManager", + return_value=mock_agent_manager, + ) as MockAM, + ): + MockCC.create.return_value = mock_ctx + + from mcp_cli.chat.chat_handler import handle_chat_mode + + result = await handle_chat_mode( + tool_mgr, + provider="openai", + model="gpt-4", + multi_agent=True, + ) + assert result is True + # AgentManager was constructed + MockAM.assert_called_once() + # agent_manager set on context + assert mock_ctx.agent_manager == mock_agent_manager + # stop_all called during cleanup + mock_agent_manager.stop_all.assert_called_once() + + @pytest.mark.asyncio + async def test_multi_agent_implies_dashboard(self): + """multi_agent=True forces dashboard=True even if not passed.""" + tool_mgr = MagicMock() + tool_mgr.close = AsyncMock() + tool_mgr.get_tool_count = MagicMock(return_value=0) + + mock_ctx = _make_ctx() + mock_ctx.initialize = AsyncMock(return_value=True) + mock_ctx.agent_id = "default" + mock_ctx.dashboard_bridge = None + mock_ctx.conversation_history = [] + mock_ctx.save_session = MagicMock(return_value=None) + + mock_app_ctx = MagicMock() + mock_app_ctx.model_manager = MagicMock() + mock_app_ctx.initialize = AsyncMock() + + ui = _make_ui() + convo = _make_convo() + + mock_bridge = MagicMock() + mock_bridge.set_context = MagicMock() + mock_bridge.set_tool_call_callback = MagicMock() + mock_bridge.set_input_queue = MagicMock() + mock_bridge.on_shutdown = AsyncMock() + mock_bridge.server = MagicMock() + mock_bridge.server.stop = AsyncMock() + + mock_launch = AsyncMock(return_value=(MagicMock(), MagicMock(), 9120)) + + with ( + patch("mcp_cli.chat.chat_handler.initialize_config"), + patch( + "mcp_cli.chat.chat_handler.initialize_context", + return_value=mock_app_ctx, + ), + patch("mcp_cli.chat.chat_handler.output"), + patch("mcp_cli.chat.chat_handler.clear_screen"), + patch("mcp_cli.chat.chat_handler.display_chat_banner"), + patch("mcp_cli.chat.chat_handler.ChatContext") as MockCC, + patch("mcp_cli.chat.chat_handler.ChatUIManager", return_value=ui), + patch( + "mcp_cli.chat.chat_handler.ConversationProcessor", + return_value=convo, + ), + patch( + "mcp_cli.chat.chat_handler._run_enhanced_chat_loop", + new_callable=AsyncMock, + ), + patch( + "mcp_cli.chat.chat_handler._safe_cleanup", + new_callable=AsyncMock, + ), + patch( + "mcp_cli.dashboard.launcher.launch_dashboard", + mock_launch, + ), + patch( + "mcp_cli.dashboard.bridge.DashboardBridge", + return_value=mock_bridge, + ), + patch( + "mcp_cli.agents.manager.AgentManager", + return_value=MagicMock(stop_all=AsyncMock()), + ), + ): + MockCC.create.return_value = mock_ctx + + from mcp_cli.chat.chat_handler import handle_chat_mode + + # dashboard=False but multi_agent=True — dashboard should still launch + result = await handle_chat_mode( + tool_mgr, + provider="openai", + model="gpt-4", + dashboard=False, + multi_agent=True, + ) + assert result is True + # launch_dashboard was called (proving dashboard was forced on) + mock_launch.assert_called_once() diff --git a/tests/chat/test_session_store.py b/tests/chat/test_session_store.py index c6874ddc..73c1967e 100644 --- a/tests/chat/test_session_store.py +++ b/tests/chat/test_session_store.py @@ -8,7 +8,7 @@ @pytest.fixture def store(tmp_path): """Session store with temporary directory.""" - return SessionStore(sessions_dir=tmp_path) + return SessionStore(sessions_dir=tmp_path, agent_id="test-agent") @pytest.fixture @@ -116,6 +116,69 @@ def test_path_traversal_prevention(self, store): assert str(store.sessions_dir) in str(path) +class TestAgentNamespacing: + def test_agent_id_in_metadata(self, store, sample_data): + """Saved sessions carry agent_id in metadata.""" + sample_data.metadata.agent_id = "test-agent" + store.save(sample_data) + loaded = store.load("test-abc123") + assert loaded is not None + assert loaded.metadata.agent_id == "test-agent" + + def test_agent_namespacing(self, tmp_path): + """Two stores with different agent_ids use isolated directories.""" + store_a = SessionStore(sessions_dir=tmp_path, agent_id="agent-a") + store_b = SessionStore(sessions_dir=tmp_path, agent_id="agent-b") + + data_a = SessionData( + metadata=SessionMetadata(session_id="shared-id", agent_id="agent-a"), + messages=[{"role": "user", "content": "from A"}], + ) + data_b = SessionData( + metadata=SessionMetadata(session_id="shared-id", agent_id="agent-b"), + messages=[{"role": "user", "content": "from B"}], + ) + store_a.save(data_a) + store_b.save(data_b) + + loaded_a = store_a.load("shared-id") + loaded_b = store_b.load("shared-id") + assert loaded_a is not None and loaded_b is not None + assert loaded_a.messages[0]["content"] == "from A" + assert loaded_b.messages[0]["content"] == "from B" + + # Each store only sees its own session + assert len(store_a.list_sessions()) == 1 + assert len(store_b.list_sessions()) == 1 + + def test_backward_compat_migration(self, tmp_path): + """Sessions in the flat root dir are auto-migrated on load.""" + # Write a session file directly to the flat root (legacy layout) + legacy_data = SessionData( + metadata=SessionMetadata(session_id="legacy-sess", provider="openai"), + messages=[{"role": "user", "content": "old"}], + ) + legacy_path = tmp_path / "legacy-sess.json" + legacy_path.write_text(legacy_data.model_dump_json(indent=2), encoding="utf-8") + + # Create a namespaced store and try to load + store = SessionStore(sessions_dir=tmp_path, agent_id="default") + loaded = store.load("legacy-sess") + assert loaded is not None + assert loaded.metadata.session_id == "legacy-sess" + + # Legacy file should be gone (migrated) + assert not legacy_path.exists() + # Now lives in the namespaced dir + assert (tmp_path / "default" / "legacy-sess.json").exists() + + def test_sessions_dir_is_agent_scoped(self, tmp_path): + """SessionStore.sessions_dir points to the agent subdirectory.""" + store = SessionStore(sessions_dir=tmp_path, agent_id="my-agent") + assert store.sessions_dir == tmp_path / "my-agent" + assert store.sessions_dir.exists() + + class TestAutoSave: def test_auto_save_triggers(self): """Auto-save fires after N turns.""" diff --git a/tests/chat/test_streaming_handler.py b/tests/chat/test_streaming_handler.py index 335e67a7..33f55f39 100644 --- a/tests/chat/test_streaming_handler.py +++ b/tests/chat/test_streaming_handler.py @@ -3,6 +3,7 @@ import asyncio import json +import logging import pytest from unittest.mock import AsyncMock, MagicMock, patch @@ -893,7 +894,7 @@ async def mock_aiter(): assert 180.0 not in timeouts_used, f"Did not expect 180.0 in {timeouts_used}" @pytest.mark.asyncio - async def test_after_tool_calls_timeout_message_mentions_tool_results(self): + async def test_after_tool_calls_timeout_message_mentions_tool_results(self, caplog): """Timeout message after tool calls mentions tool result processing.""" display = _make_display() # Use very short first_chunk to trigger timeout @@ -909,7 +910,7 @@ async def mock_aiter(): # Patch the after-tools default to a tiny value so timeout fires with ( - patch("chuk_term.ui.output") as mock_output, + caplog.at_level(logging.WARNING, logger="mcp_cli.streaming"), patch( "mcp_cli.config.defaults.DEFAULT_STREAMING_FIRST_CHUNK_AFTER_TOOLS_TIMEOUT", 0.01, @@ -917,7 +918,10 @@ async def mock_aiter(): ): await handler.stream_response(client, messages=[], after_tool_calls=True) - # Check that the error message mentions tool results - mock_output.error.assert_called_once() - error_msg = mock_output.error.call_args[0][0] - assert "tool" in error_msg.lower() + # Check that the warning message mentions tool calls + warning_msgs = [ + r.message + for r in caplog.records + if r.levelname == "WARNING" and r.name == "mcp_cli.streaming" + ] + assert any("tool" in m.lower() for m in warning_msgs) diff --git a/tests/chat/test_tool_processor_extended.py b/tests/chat/test_tool_processor_extended.py index e2ae0584..3a07c580 100644 --- a/tests/chat/test_tool_processor_extended.py +++ b/tests/chat/test_tool_processor_extended.py @@ -1476,7 +1476,7 @@ async def test_missing_reference_blocks_tool(self): with ( patch.object(tp, "_should_confirm_tool", return_value=False), patch( - "mcp_cli.chat.tool_processor.get_tool_state", + "mcp_cli.chat.tool_processor.get_agent_tool_state", return_value=mock_tool_state, ), ): @@ -1540,7 +1540,9 @@ async def test_precondition_failure_blocks_tool(self): with ( patch.object(tp, "_should_confirm_tool", return_value=False), - patch("mcp_cli.chat.tool_processor.get_tool_state", return_value=mock_ts), + patch( + "mcp_cli.chat.tool_processor.get_agent_tool_state", return_value=mock_ts + ), ): await tp.process_tool_calls([tc]) @@ -1616,7 +1618,9 @@ async def test_repair_succeeds_rebind(self): with ( patch.object(tp, "_should_confirm_tool", return_value=False), - patch("mcp_cli.chat.tool_processor.get_tool_state", return_value=mock_ts), + patch( + "mcp_cli.chat.tool_processor.get_agent_tool_state", return_value=mock_ts + ), ): await tp.process_tool_calls([tc]) @@ -1641,7 +1645,9 @@ async def test_repair_symbolic_fallback(self): with ( patch.object(tp, "_should_confirm_tool", return_value=False), - patch("mcp_cli.chat.tool_processor.get_tool_state", return_value=mock_ts), + patch( + "mcp_cli.chat.tool_processor.get_agent_tool_state", return_value=mock_ts + ), ): await tp.process_tool_calls([tc]) @@ -1667,7 +1673,9 @@ async def test_repair_all_failed(self): with ( patch.object(tp, "_should_confirm_tool", return_value=False), - patch("mcp_cli.chat.tool_processor.get_tool_state", return_value=mock_ts), + patch( + "mcp_cli.chat.tool_processor.get_agent_tool_state", return_value=mock_ts + ), ): await tp.process_tool_calls([tc]) @@ -1729,7 +1737,9 @@ async def test_per_tool_limit_blocks(self): with ( patch.object(tp, "_should_confirm_tool", return_value=False), - patch("mcp_cli.chat.tool_processor.get_tool_state", return_value=mock_ts), + patch( + "mcp_cli.chat.tool_processor.get_agent_tool_state", return_value=mock_ts + ), ): await tp.process_tool_calls([tc]) @@ -1798,7 +1808,9 @@ async def test_requires_justification_logged(self): mock_search_engine = MagicMock() with ( - patch("mcp_cli.chat.tool_processor.get_tool_state", return_value=mock_ts), + patch( + "mcp_cli.chat.tool_processor.get_agent_tool_state", return_value=mock_ts + ), patch( "mcp_cli.chat.tool_processor.get_search_engine", return_value=mock_search_engine, @@ -1857,7 +1869,9 @@ async def test_discovery_tool_result(self): mock_search_engine = MagicMock() with ( - patch("mcp_cli.chat.tool_processor.get_tool_state", return_value=mock_ts), + patch( + "mcp_cli.chat.tool_processor.get_agent_tool_state", return_value=mock_ts + ), patch( "mcp_cli.chat.tool_processor.get_search_engine", return_value=mock_search_engine, diff --git a/tests/chat/test_ui_manager_coverage.py b/tests/chat/test_ui_manager_coverage.py index 6ac68f2b..ba40f5c4 100644 --- a/tests/chat/test_ui_manager_coverage.py +++ b/tests/chat/test_ui_manager_coverage.py @@ -372,8 +372,7 @@ async def test_routes_to_dashboard_when_clients_connected(self, ui_manager): fut.set_result(True) bridge = MagicMock() - bridge.server = MagicMock() - bridge.server.has_clients = True + bridge.has_clients = True bridge.request_tool_approval = AsyncMock(return_value=fut) ui_manager.context.dashboard_bridge = bridge @@ -385,8 +384,7 @@ async def test_routes_to_dashboard_when_clients_connected(self, ui_manager): async def test_falls_back_to_terminal_when_no_clients(self, ui_manager): """When dashboard has no clients, fall back to terminal input.""" bridge = MagicMock() - bridge.server = MagicMock() - bridge.server.has_clients = False + bridge.has_clients = False ui_manager.context.dashboard_bridge = bridge with ( diff --git a/tests/config/test_cli_options.py b/tests/config/test_cli_options.py index 81d9a9d4..bb29871e 100644 --- a/tests/config/test_cli_options.py +++ b/tests/config/test_cli_options.py @@ -328,11 +328,10 @@ def test_process_options_quiet_mode(mock_discovery, monkeypatch, tmp_path, caplo pass -@patch("chuk_term.ui.output") @patch("mcp_cli.config.cli_options.trigger_discovery_after_setup") @patch("mcp_cli.utils.preferences.get_preference_manager") def test_process_options_disabled_server_blocked( - mock_pref_manager, mock_discovery, mock_output, monkeypatch, tmp_path, caplog + mock_pref_manager, mock_discovery, monkeypatch, tmp_path, caplog ): """Test that disabled servers are blocked even when explicitly requested.""" mock_discovery.return_value = 0 @@ -370,10 +369,12 @@ def test_process_options_disabled_server_blocked( # specified should still contain what was requested assert specified == ["DisabledServer"] - # Should have called output.warning about disabled server - mock_output.warning.assert_called() - warning_calls = [str(call) for call in mock_output.warning.call_args_list] - assert any("disabled and cannot be used" in str(call) for call in warning_calls) + # Should have logged warning about disabled server + assert any( + "disabled" in r.message.lower() + for r in caplog.records + if r.levelname == "WARNING" + ) @patch("mcp_cli.config.cli_options.trigger_discovery_after_setup") diff --git a/tests/dashboard/test_bridge_extended.py b/tests/dashboard/test_bridge_extended.py index 2f7b45fd..c6871f52 100644 --- a/tests/dashboard/test_bridge_extended.py +++ b/tests/dashboard/test_bridge_extended.py @@ -24,7 +24,7 @@ # --------------------------------------------------------------------------- -def _make_bridge(): +def _make_bridge(agent_id: str = "test-agent"): from mcp_cli.dashboard.bridge import DashboardBridge from mcp_cli.dashboard.server import DashboardServer @@ -34,7 +34,7 @@ def _make_bridge(): server.on_client_connected = None server.on_client_disconnected = None server.has_clients = True - bridge = DashboardBridge(server) + bridge = DashboardBridge(server, agent_id=agent_id) return bridge, server @@ -254,7 +254,7 @@ async def test_broadcast_uses_envelope(self): msg = server.broadcast.call_args[0][0] # Should be wrapped in envelope format assert msg["protocol"] == "mcp-dashboard" - assert msg["version"] == 1 + assert msg["version"] == 2 assert msg["type"] == "VIEW_REGISTRY" assert msg["payload"]["views"] == bridge._view_registry @@ -537,3 +537,138 @@ def test_multiple_tool_calls_in_one_message(self): assert len(tool_events) == 2 names = {e["payload"]["tool_name"] for e in tool_events} assert names == {"tool_a", "tool_b"} + + +# --------------------------------------------------------------------------- +# agent_id plumbing +# --------------------------------------------------------------------------- + + +class TestAgentIdInPayloads: + """Verify agent_id from bridge constructor appears in all payloads.""" + + @pytest.mark.asyncio + async def test_agent_id_in_tool_result_payload(self): + bridge, server = _make_bridge(agent_id="agent-x") + await bridge.on_tool_result( + tool_name="test_tool", + server_name="srv", + result="ok", + success=True, + ) + msg = server.broadcast.call_args[0][0] + assert msg["payload"]["agent_id"] == "agent-x" + + @pytest.mark.asyncio + async def test_agent_id_in_agent_state_payload(self): + bridge, server = _make_bridge(agent_id="agent-y") + await bridge.on_agent_state(status="thinking") + msg = server.broadcast.call_args[0][0] + assert msg["payload"]["agent_id"] == "agent-y" + + @pytest.mark.asyncio + async def test_agent_id_in_message_payload(self): + bridge, server = _make_bridge(agent_id="agent-z") + await bridge.on_message(role="user", content="hello") + msg = server.broadcast.call_args[0][0] + assert msg["payload"]["agent_id"] == "agent-z" + + @pytest.mark.asyncio + async def test_agent_id_in_plan_update_payload(self): + bridge, server = _make_bridge(agent_id="planner") + await bridge.on_plan_update( + plan_id="p1", title="Test", steps=[], status="running" + ) + msg = server.broadcast.call_args[0][0] + assert msg["payload"]["agent_id"] == "planner" + + def test_agent_id_in_activity_history(self): + bridge, _ = _make_bridge(agent_id="replay-agent") + + class FakeMsg: + def __init__(self, d): + self._d = d + + def to_dict(self): + return self._d + + ctx = MagicMock() + ctx.conversation_history = [ + FakeMsg( + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "c1", + "function": {"name": "tool_a", "arguments": "{}"}, + } + ], + } + ), + FakeMsg( + { + "role": "tool", + "content": "result", + "tool_call_id": "c1", + } + ), + ] + bridge.set_context(ctx) + events = bridge._build_activity_history() + tool_events = [e for e in events if e["type"] == "TOOL_RESULT"] + assert len(tool_events) == 1 + assert tool_events[0]["payload"]["agent_id"] == "replay-agent" + + def test_protocol_version_is_2(self): + from mcp_cli.dashboard.bridge import _VERSION + + assert _VERSION == 2 + + +# --------------------------------------------------------------------------- +# Bridge with AgentRouter +# --------------------------------------------------------------------------- + + +class TestBridgeWithRouter: + """Verify bridge works correctly when constructed with an AgentRouter.""" + + def _make_router_bridge(self, agent_id: str = "routed-agent"): + from mcp_cli.dashboard.bridge import DashboardBridge + from mcp_cli.dashboard.router import AgentRouter + from mcp_cli.dashboard.server import DashboardServer + + server = MagicMock(spec=DashboardServer) + server.broadcast = AsyncMock() + server.send_to_client = AsyncMock() + server.has_clients = True + server.on_browser_message = None + server.on_client_connected = None + server.on_client_disconnected = None + + router = AgentRouter(server) + bridge = DashboardBridge(router, agent_id=agent_id) + return bridge, router, server + + @pytest.mark.asyncio + async def test_broadcast_goes_through_router(self): + bridge, router, server = self._make_router_bridge("agent-r") + await bridge.on_agent_state(status="thinking") + # Should ultimately reach server.broadcast via router + server.broadcast.assert_awaited_once() + msg = server.broadcast.call_args[0][0] + assert msg["type"] == "AGENT_STATE" + assert msg["payload"]["agent_id"] == "agent-r" + + def test_has_clients_proxies_through_router(self): + bridge, router, server = self._make_router_bridge() + server.has_clients = True + assert bridge.has_clients is True + server.has_clients = False + assert bridge.has_clients is False + + def test_server_attribute_still_accessible(self): + bridge, router, server = self._make_router_bridge() + # bridge.server should point to the DashboardServer, not the router + assert bridge.server is server diff --git a/tests/dashboard/test_launcher.py b/tests/dashboard/test_launcher.py index 1d7680e3..0baa902a 100644 --- a/tests/dashboard/test_launcher.py +++ b/tests/dashboard/test_launcher.py @@ -7,26 +7,45 @@ import pytest +from mcp_cli.dashboard.router import AgentRouter + def _mock_server(port: int = 9120): """Return a mock DashboardServer whose start() resolves to the given port.""" srv = AsyncMock() srv.start = AsyncMock(return_value=port) + # AgentRouter needs to set these attributes + srv.on_browser_message = None + srv.on_client_connected = None + srv.on_client_disconnected = None + srv.has_clients = False return srv class TestLaunchDashboard: @pytest.mark.asyncio - async def test_returns_server_and_port(self): + async def test_returns_server_router_and_port(self): from mcp_cli.dashboard import launcher srv = _mock_server(9120) with patch.object(launcher, "DashboardServer", return_value=srv): - server, port = await launcher.launch_dashboard() + server, router, port = await launcher.launch_dashboard() assert server is srv + assert isinstance(router, AgentRouter) assert port == 9120 + @pytest.mark.asyncio + async def test_returns_router(self): + from mcp_cli.dashboard import launcher + + srv = _mock_server(9120) + with patch.object(launcher, "DashboardServer", return_value=srv): + _, router, _ = await launcher.launch_dashboard() + + assert isinstance(router, AgentRouter) + assert router.server is srv + @pytest.mark.asyncio async def test_opens_browser_when_no_browser_false(self): from mcp_cli.dashboard import launcher @@ -53,7 +72,7 @@ async def test_webbrowser_exception_suppressed(self): with patch.object(launcher, "DashboardServer", return_value=_mock_server(9120)): with patch("webbrowser.open", side_effect=Exception("no display")): - server, port = await launcher.launch_dashboard(no_browser=False) + server, router, port = await launcher.launch_dashboard(no_browser=False) assert port == 9120 # function completed successfully @@ -64,7 +83,7 @@ async def test_preferred_port_passed_to_server(self): srv = _mock_server(8080) with patch.object(launcher, "DashboardServer", return_value=srv): with patch("webbrowser.open"): - _, port = await launcher.launch_dashboard(port=8080) + _, _, port = await launcher.launch_dashboard(port=8080) srv.start.assert_called_once_with(8080) assert port == 8080 diff --git a/tests/dashboard/test_router.py b/tests/dashboard/test_router.py new file mode 100644 index 00000000..01650afc --- /dev/null +++ b/tests/dashboard/test_router.py @@ -0,0 +1,535 @@ +# tests/dashboard/test_router.py +"""Unit tests for AgentRouter.""" + +from __future__ import annotations + +from dataclasses import asdict +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from mcp_cli.dashboard.router import AgentDescriptor, AgentRouter + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_server(): + """Return a mock DashboardServer.""" + from mcp_cli.dashboard.server import DashboardServer + + server = MagicMock(spec=DashboardServer) + server.broadcast = AsyncMock() + server.send_to_client = AsyncMock() + server.has_clients = True + server.on_browser_message = None + server.on_client_connected = None + server.on_client_disconnected = None + return server + + +def _make_bridge(agent_id: str = "agent-1"): + """Return a mock DashboardBridge.""" + bridge = MagicMock() + bridge.agent_id = agent_id + bridge._on_browser_message = AsyncMock() + bridge._on_client_connected = AsyncMock() + bridge.on_client_disconnected = AsyncMock() + return bridge + + +# --------------------------------------------------------------------------- +# TestRouterSingleAgent +# --------------------------------------------------------------------------- + + +class TestRouterSingleAgent: + """Single-bridge router behaves identically to direct wiring.""" + + @pytest.mark.asyncio + async def test_broadcast_from_agent_delegates_to_server(self): + server = _make_server() + router = AgentRouter(server) + bridge = _make_bridge("agent-1") + router.register_agent("agent-1", bridge) + + msg = {"type": "TEST", "payload": {}} + await router.broadcast_from_agent("agent-1", msg) + server.broadcast.assert_awaited_once_with(msg) + + @pytest.mark.asyncio + async def test_browser_message_routed_to_sole_bridge(self): + server = _make_server() + router = AgentRouter(server) + bridge = _make_bridge("agent-1") + router.register_agent("agent-1", bridge) + + msg = {"type": "USER_MESSAGE", "content": "hi"} + await router._on_browser_message(msg) + bridge._on_browser_message.assert_awaited_once_with(msg) + + @pytest.mark.asyncio + async def test_browser_message_with_agent_id_routes_correctly(self): + server = _make_server() + router = AgentRouter(server) + bridge = _make_bridge("agent-1") + router.register_agent("agent-1", bridge) + + msg = {"type": "USER_MESSAGE", "content": "hi", "agent_id": "agent-1"} + await router._on_browser_message(msg) + bridge._on_browser_message.assert_awaited_once_with(msg) + + @pytest.mark.asyncio + async def test_client_connected_delegates_to_bridge(self): + server = _make_server() + router = AgentRouter(server) + bridge = _make_bridge("agent-1") + router.register_agent("agent-1", bridge) + + ws = AsyncMock() + await router._on_client_connected(ws) + bridge._on_client_connected.assert_awaited_once_with(ws) + + @pytest.mark.asyncio + async def test_client_disconnected_delegates_to_bridge(self): + server = _make_server() + router = AgentRouter(server) + bridge = _make_bridge("agent-1") + router.register_agent("agent-1", bridge) + + await router._on_client_disconnected() + bridge.on_client_disconnected.assert_awaited_once() + + def test_has_clients_proxies_to_server(self): + server = _make_server() + server.has_clients = True + router = AgentRouter(server) + assert router.has_clients is True + server.has_clients = False + assert router.has_clients is False + + +# --------------------------------------------------------------------------- +# TestRouterRegistration +# --------------------------------------------------------------------------- + + +class TestRouterRegistration: + def test_register_agent_adds_bridge(self): + server = _make_server() + router = AgentRouter(server) + bridge = _make_bridge("a") + router.register_agent("a", bridge) + assert "a" in router._bridges + + def test_unregister_agent_removes_bridge(self): + server = _make_server() + router = AgentRouter(server) + bridge = _make_bridge("a") + router.register_agent("a", bridge) + router.unregister_agent("a") + assert "a" not in router._bridges + + def test_register_multiple_agents(self): + server = _make_server() + router = AgentRouter(server) + b1 = _make_bridge("a") + b2 = _make_bridge("b") + router.register_agent("a", b1) + router.register_agent("b", b2) + assert len(router._bridges) == 2 + + +# --------------------------------------------------------------------------- +# TestRouterTwoAgents +# --------------------------------------------------------------------------- + + +class TestRouterTwoAgents: + @pytest.mark.asyncio + async def test_message_with_unknown_agent_id_logs_debug(self): + server = _make_server() + router = AgentRouter(server) + b1 = _make_bridge("a") + b2 = _make_bridge("b") + router.register_agent("a", b1) + router.register_agent("b", b2) + + msg = {"type": "USER_MESSAGE", "agent_id": "unknown"} + await router._on_browser_message(msg) + b1._on_browser_message.assert_not_awaited() + b2._on_browser_message.assert_not_awaited() + + @pytest.mark.asyncio + async def test_client_connected_replays_default_bridge_only(self): + server = _make_server() + router = AgentRouter(server) + b1 = _make_bridge("a") + b2 = _make_bridge("b") + router.register_agent("a", b1) + router.register_agent("b", b2) + + ws = AsyncMock() + await router._on_client_connected(ws) + # Only the default (first registered) agent is replayed + b1._on_client_connected.assert_awaited_once_with(ws) + b2._on_client_connected.assert_not_awaited() + + @pytest.mark.asyncio + async def test_client_disconnected_notifies_all_bridges(self): + server = _make_server() + router = AgentRouter(server) + b1 = _make_bridge("a") + b2 = _make_bridge("b") + router.register_agent("a", b1) + router.register_agent("b", b2) + + await router._on_client_disconnected() + b1.on_client_disconnected.assert_awaited_once() + b2.on_client_disconnected.assert_awaited_once() + + +# --------------------------------------------------------------------------- +# TestAgentDescriptor +# --------------------------------------------------------------------------- + + +class TestAgentDescriptor: + def test_defaults(self): + desc = AgentDescriptor(agent_id="a1", name="Agent One") + assert desc.agent_id == "a1" + assert desc.name == "Agent One" + assert desc.status == "active" + assert desc.role == "" + assert desc.model == "" + assert desc.parent_agent_id is None + assert desc.tool_count == 0 + assert desc.message_count == 0 + + def test_asdict_roundtrip(self): + desc = AgentDescriptor(agent_id="x", name="X", role="worker", model="gpt-4") + d = asdict(desc) + assert d["agent_id"] == "x" + assert d["role"] == "worker" + assert d["model"] == "gpt-4" + + +# --------------------------------------------------------------------------- +# TestRouterFocusTracking +# --------------------------------------------------------------------------- + + +class TestRouterFocusTracking: + @pytest.mark.asyncio + async def test_focus_agent_stores_focus(self): + server = _make_server() + router = AgentRouter(server) + b1 = _make_bridge("a") + b2 = _make_bridge("b") + router.register_agent("a", b1) + router.register_agent("b", b2) + + ws = AsyncMock() + msg = {"type": "FOCUS_AGENT", "agent_id": "b"} + await router._on_browser_message(msg, ws) + assert router._client_focus[ws] == "b" + + @pytest.mark.asyncio + async def test_focus_agent_replays_only_focused_bridge(self): + server = _make_server() + router = AgentRouter(server) + b1 = _make_bridge("a") + b2 = _make_bridge("b") + router.register_agent("a", b1) + router.register_agent("b", b2) + + ws = AsyncMock() + msg = {"type": "FOCUS_AGENT", "agent_id": "b"} + await router._on_browser_message(msg, ws) + b2._on_client_connected.assert_awaited_once_with(ws) + b1._on_client_connected.assert_not_awaited() + + @pytest.mark.asyncio + async def test_browser_message_uses_focus_when_no_agent_id(self): + server = _make_server() + router = AgentRouter(server) + b1 = _make_bridge("a") + b2 = _make_bridge("b") + router.register_agent("a", b1) + router.register_agent("b", b2) + + ws = AsyncMock() + # Set focus to "b" + router._client_focus[ws] = "b" + msg = {"type": "USER_MESSAGE", "content": "hi"} + await router._on_browser_message(msg, ws) + b2._on_browser_message.assert_awaited_once_with(msg) + b1._on_browser_message.assert_not_awaited() + + +# --------------------------------------------------------------------------- +# TestRouterAgentList +# --------------------------------------------------------------------------- + + +class TestRouterAgentList: + @pytest.mark.asyncio + async def test_client_connected_sends_agent_list(self): + server = _make_server() + router = AgentRouter(server) + bridge = _make_bridge("a") + router.register_agent("a", bridge) + + ws = AsyncMock() + await router._on_client_connected(ws) + # send_to_client is called with AGENT_LIST envelope + server.send_to_client.assert_awaited() + call_args = server.send_to_client.call_args_list[0] + msg = call_args[0][1] # second positional arg + assert msg["type"] == "AGENT_LIST" + assert len(msg["payload"]["agents"]) == 1 + assert msg["payload"]["agents"][0]["agent_id"] == "a" + + @pytest.mark.asyncio + async def test_request_agent_list_not_forwarded_to_bridge(self): + server = _make_server() + router = AgentRouter(server) + bridge = _make_bridge("a") + router.register_agent("a", bridge) + + ws = AsyncMock() + msg = {"type": "REQUEST_AGENT_LIST"} + await router._on_browser_message(msg, ws) + bridge._on_browser_message.assert_not_awaited() + server.send_to_client.assert_awaited() + + @pytest.mark.asyncio + async def test_client_connected_sets_default_focus(self): + server = _make_server() + router = AgentRouter(server) + bridge = _make_bridge("a") + router.register_agent("a", bridge) + + ws = AsyncMock() + await router._on_client_connected(ws) + assert router._client_focus[ws] == "a" + + +# --------------------------------------------------------------------------- +# TestRouterDescriptorBackwardCompat +# --------------------------------------------------------------------------- + + +class TestRouterDescriptorBackwardCompat: + def test_register_without_descriptor_creates_default(self): + server = _make_server() + router = AgentRouter(server) + bridge = _make_bridge("test-agent") + router.register_agent("test-agent", bridge) + desc = router._agent_descriptors["test-agent"] + assert desc.agent_id == "test-agent" + assert desc.name == "test-agent" + assert desc.status == "active" + + def test_register_with_custom_descriptor(self): + server = _make_server() + router = AgentRouter(server) + bridge = _make_bridge("a") + desc = AgentDescriptor(agent_id="a", name="Custom", role="planner") + router.register_agent("a", bridge, descriptor=desc) + assert router._agent_descriptors["a"].name == "Custom" + assert router._agent_descriptors["a"].role == "planner" + + +# --------------------------------------------------------------------------- +# TestBroadcastGlobal +# --------------------------------------------------------------------------- + + +class TestBroadcastGlobal: + @pytest.mark.asyncio + async def test_broadcast_global_delegates_to_server(self): + server = _make_server() + router = AgentRouter(server) + msg = {"type": "AGENT_LIST", "payload": {}} + await router.broadcast_global(msg) + server.broadcast.assert_awaited_once_with(msg) + + +# --------------------------------------------------------------------------- +# TestUpdateAgentStatus +# --------------------------------------------------------------------------- + + +class TestUpdateAgentStatus: + @pytest.mark.asyncio + async def test_update_status_changes_descriptor_and_broadcasts(self): + server = _make_server() + router = AgentRouter(server) + bridge = _make_bridge("a") + router.register_agent("a", bridge) + + await router.update_agent_status("a", "paused") + assert router._agent_descriptors["a"].status == "paused" + server.broadcast.assert_awaited_once() + msg = server.broadcast.call_args[0][0] + assert msg["type"] == "AGENT_STATUS" + assert msg["payload"]["agent_id"] == "a" + assert msg["payload"]["status"] == "paused" + + @pytest.mark.asyncio + async def test_update_status_unknown_agent_does_nothing(self): + server = _make_server() + router = AgentRouter(server) + await router.update_agent_status("nonexistent", "failed") + server.broadcast.assert_not_awaited() + + +# --------------------------------------------------------------------------- +# TestRouterAgentMessage +# --------------------------------------------------------------------------- + + +class TestRouterAgentMessage: + @pytest.mark.asyncio + async def test_agent_message_forwarded_to_target(self): + server = _make_server() + router = AgentRouter(server) + b1 = _make_bridge("a") + b2 = _make_bridge("b") + router.register_agent("a", b1) + router.register_agent("b", b2) + + msg = { + "type": "AGENT_MESSAGE", + "from_agent": "a", + "to_agent": "b", + "content": "hello", + } + await router._on_browser_message(msg) + b2._on_browser_message.assert_awaited_once() + call_msg = b2._on_browser_message.call_args[0][0] + assert call_msg["type"] == "USER_MESSAGE" + assert "hello" in call_msg["content"] + b1._on_browser_message.assert_not_awaited() + + @pytest.mark.asyncio + async def test_agent_message_to_unknown_is_noop(self): + server = _make_server() + router = AgentRouter(server) + b1 = _make_bridge("a") + router.register_agent("a", b1) + + msg = { + "type": "AGENT_MESSAGE", + "from_agent": "a", + "to_agent": "nonexistent", + "content": "hello", + } + await router._on_browser_message(msg) + b1._on_browser_message.assert_not_awaited() + + +# --------------------------------------------------------------------------- +# TestRouterSubscriptions +# --------------------------------------------------------------------------- + + +class TestRouterSubscriptions: + @pytest.mark.asyncio + async def test_subscribe_stores_subscriptions(self): + server = _make_server() + router = AgentRouter(server) + ws = AsyncMock() + msg = {"type": "SUBSCRIBE", "agents": ["a", "b"], "global": False} + await router._on_browser_message(msg, ws) + assert router._client_subscriptions[ws] == {"a", "b"} + + @pytest.mark.asyncio + async def test_subscribe_global_adds_wildcard(self): + server = _make_server() + router = AgentRouter(server) + ws = AsyncMock() + msg = {"type": "SUBSCRIBE", "agents": ["a"], "global": True} + await router._on_browser_message(msg, ws) + assert "*" in router._client_subscriptions[ws] + assert "a" in router._client_subscriptions[ws] + + @pytest.mark.asyncio + async def test_broadcast_from_agent_filters_by_subscription(self): + server = _make_server() + router = AgentRouter(server) + ws1 = AsyncMock() + ws2 = AsyncMock() + # ws1 subscribes to "a" only, ws2 subscribes to all + router._client_subscriptions[ws1] = {"a"} + router._client_subscriptions[ws2] = {"*"} + + msg = {"type": "TOOL_RESULT", "payload": {}} + await router.broadcast_from_agent("b", msg) + # ws1 is NOT subscribed to "b", so only ws2 should receive + calls = server.send_to_client.call_args_list + assert len(calls) == 1 + assert calls[0][0][0] is ws2 + + @pytest.mark.asyncio + async def test_broadcast_from_agent_no_subscriptions_broadcasts_all(self): + server = _make_server() + router = AgentRouter(server) + # No subscriptions configured — should broadcast to all + msg = {"type": "TOOL_RESULT", "payload": {}} + await router.broadcast_from_agent("a", msg) + server.broadcast.assert_awaited_once_with(msg) + + @pytest.mark.asyncio + async def test_client_connected_sets_default_subscription(self): + server = _make_server() + router = AgentRouter(server) + bridge = _make_bridge("a") + router.register_agent("a", bridge) + + ws = AsyncMock() + await router._on_client_connected(ws) + assert router._client_subscriptions[ws] == {"*"} + + @pytest.mark.asyncio + async def test_client_disconnected_cleans_up_state(self): + server = _make_server() + router = AgentRouter(server) + bridge = _make_bridge("a") + router.register_agent("a", bridge) + + ws = AsyncMock() + await router._on_client_connected(ws) + assert ws in router._client_focus + assert ws in router._client_subscriptions + + await router._on_client_disconnected(ws) + assert ws not in router._client_focus + assert ws not in router._client_subscriptions + + @pytest.mark.asyncio + async def test_client_disconnected_without_ws_still_works(self): + """Backward compat: calling with no ws arg doesn't crash.""" + server = _make_server() + router = AgentRouter(server) + bridge = _make_bridge("a") + router.register_agent("a", bridge) + + await router._on_client_disconnected() + bridge.on_client_disconnected.assert_awaited_once() + + @pytest.mark.asyncio + async def test_subscribe_replaces_previous(self): + server = _make_server() + router = AgentRouter(server) + ws = AsyncMock() + msg1 = {"type": "SUBSCRIBE", "agents": ["a", "b"], "global": False} + await router._on_browser_message(msg1, ws) + assert router._client_subscriptions[ws] == {"a", "b"} + + msg2 = {"type": "SUBSCRIBE", "agents": ["c"], "global": True} + await router._on_browser_message(msg2, ws) + assert router._client_subscriptions[ws] == {"c", "*"} diff --git a/tests/dashboard/test_server.py b/tests/dashboard/test_server.py index d5042e23..4363e83d 100644 --- a/tests/dashboard/test_server.py +++ b/tests/dashboard/test_server.py @@ -148,6 +148,73 @@ async def on_connect(ws): await result assert fake_ws in connected + @pytest.mark.asyncio + async def test_handle_browser_message_passes_ws_to_two_arg_callback(self): + from mcp_cli.dashboard.server import DashboardServer + + s = DashboardServer() + received = [] + + async def handler(msg, ws): + received.append((msg, ws)) + + s.on_browser_message = handler + fake_ws = AsyncMock() + await s._handle_browser_message( + '{"type":"USER_MESSAGE","content":"hi"}', fake_ws + ) + assert len(received) == 1 + assert received[0][0] == {"type": "USER_MESSAGE", "content": "hi"} + assert received[0][1] is fake_ws + + @pytest.mark.asyncio + async def test_handle_browser_message_fallback_to_one_arg_callback(self): + from mcp_cli.dashboard.server import DashboardServer + + s = DashboardServer() + received = [] + + async def handler(msg): + received.append(msg) + + s.on_browser_message = handler + fake_ws = AsyncMock() + await s._handle_browser_message( + '{"type":"USER_MESSAGE","content":"hi"}', fake_ws + ) + assert len(received) == 1 + assert received[0] == {"type": "USER_MESSAGE", "content": "hi"} + + +# --------------------------------------------------------------------------- +# send_to_client +# --------------------------------------------------------------------------- + + +class TestSendToClient: + @pytest.mark.asyncio + async def test_send_to_client_basic(self): + from mcp_cli.dashboard.server import DashboardServer + + s = DashboardServer() + ws = AsyncMock() + s._clients.add(ws) + await s.send_to_client(ws, {"type": "TEST"}) + ws.send.assert_awaited_once() + payload = json.loads(ws.send.call_args[0][0]) + assert payload["type"] == "TEST" + + @pytest.mark.asyncio + async def test_send_to_client_dead_ws_discarded(self): + from mcp_cli.dashboard.server import DashboardServer + + s = DashboardServer() + ws = AsyncMock() + ws.send.side_effect = Exception("connection closed") + s._clients.add(ws) + await s.send_to_client(ws, {"type": "TEST"}) + assert ws not in s._clients + # --------------------------------------------------------------------------- # _find_port From e120ace7bfc98d2a372a2851f1f7e5b480aedb8c Mon Sep 17 00:00:00 2001 From: chrishayuk Date: Sat, 28 Feb 2026 21:33:31 +0000 Subject: [PATCH 3/7] added multimedia experince --- src/mcp_cli/chat/attachments.py | 396 +++++++++++++++ src/mcp_cli/chat/chat_context.py | 31 +- src/mcp_cli/chat/chat_handler.py | 72 ++- src/mcp_cli/commands/__init__.py | 4 + src/mcp_cli/commands/attach/__init__.py | 7 + src/mcp_cli/commands/attach/attach.py | 152 ++++++ src/mcp_cli/config/defaults.py | 20 + src/mcp_cli/dashboard/bridge.py | 115 ++++- .../static/views/activity-stream.html | 27 + .../static/views/agent-terminal.html | 96 +++- src/mcp_cli/main.py | 12 + tests/chat/test_attachments.py | 480 ++++++++++++++++++ tests/chat/test_chat_handler_coverage.py | 4 + tests/commands/attach/__init__.py | 0 tests/commands/attach/test_attach_command.py | 221 ++++++++ tests/dashboard/test_bridge_extended.py | 132 +++++ 16 files changed, 1756 insertions(+), 13 deletions(-) create mode 100644 src/mcp_cli/chat/attachments.py create mode 100644 src/mcp_cli/commands/attach/__init__.py create mode 100644 src/mcp_cli/commands/attach/attach.py create mode 100644 tests/chat/test_attachments.py create mode 100644 tests/commands/attach/__init__.py create mode 100644 tests/commands/attach/test_attach_command.py diff --git a/src/mcp_cli/chat/attachments.py b/src/mcp_cli/chat/attachments.py new file mode 100644 index 00000000..6fe9dcc1 --- /dev/null +++ b/src/mcp_cli/chat/attachments.py @@ -0,0 +1,396 @@ +# mcp_cli/chat/attachments.py +"""Multi-modal attachment processing. + +Handles file detection, MIME typing, base64 encoding, content block +construction, and staging for the ``/attach`` command. This is a +**core module** — logging only, no ``chuk_term`` imports. +""" + +from __future__ import annotations + +import base64 +import logging +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from mcp_cli.config.defaults import ( + DEFAULT_DASHBOARD_INLINE_IMAGE_THRESHOLD, + DEFAULT_DASHBOARD_TEXT_PREVIEW_CHARS, + DEFAULT_IMAGE_DETAIL_LEVEL, + DEFAULT_MAX_ATTACHMENT_SIZE_BYTES, +) + +logger = logging.getLogger(__name__) + +# ------------------------------------------------------------------ # +# MIME / extension maps # +# ------------------------------------------------------------------ # + +MIME_MAP: dict[str, str] = { + # Images + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + # Audio + ".mp3": "audio/mpeg", + ".wav": "audio/wav", + # Text / code + ".txt": "text/plain", + ".md": "text/markdown", + ".csv": "text/csv", + ".json": "application/json", + ".html": "text/html", + ".xml": "text/xml", + ".yaml": "text/yaml", + ".yml": "text/yaml", + # Programming languages + ".py": "text/plain", + ".js": "text/plain", + ".ts": "text/plain", + ".jsx": "text/plain", + ".tsx": "text/plain", + ".sh": "text/plain", + ".bash": "text/plain", + ".rs": "text/plain", + ".go": "text/plain", + ".java": "text/plain", + ".c": "text/plain", + ".cpp": "text/plain", + ".h": "text/plain", + ".hpp": "text/plain", + ".rb": "text/plain", + ".swift": "text/plain", + ".kt": "text/plain", + ".sql": "text/plain", + ".toml": "text/plain", + ".ini": "text/plain", + ".cfg": "text/plain", + ".env": "text/plain", + ".log": "text/plain", +} + +IMAGE_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp"}) +AUDIO_EXTENSIONS = frozenset({".mp3", ".wav"}) +TEXT_EXTENSIONS = frozenset(MIME_MAP.keys()) - IMAGE_EXTENSIONS - AUDIO_EXTENSIONS + +# Audio format mapping (extension → OpenAI input_audio format value) +_AUDIO_FORMAT: dict[str, str] = {".mp3": "mp3", ".wav": "wav"} + + +# ------------------------------------------------------------------ # +# Data types # +# ------------------------------------------------------------------ # + + +@dataclass +class Attachment: + """A single processed attachment ready for content-block injection.""" + + source: str + content_blocks: list[dict[str, Any]] + display_name: str + size_bytes: int + mime_type: str + + +class AttachmentStaging: + """Staging area for ``/attach`` command. Lives on ChatContext.""" + + def __init__(self) -> None: + self._pending: list[Attachment] = [] + + def stage(self, attachment: Attachment) -> None: + """Add an attachment to the staging area.""" + self._pending.append(attachment) + + def drain(self) -> list[Attachment]: + """Return all pending attachments and clear the staging area.""" + items = list(self._pending) + self._pending.clear() + return items + + def peek(self) -> list[Attachment]: + """Return pending attachments without clearing.""" + return list(self._pending) + + def clear(self) -> None: + """Clear the staging area.""" + self._pending.clear() + + @property + def count(self) -> int: + return len(self._pending) + + +# ------------------------------------------------------------------ # +# MIME detection # +# ------------------------------------------------------------------ # + + +def detect_mime_type(path: Path) -> str: + """Detect MIME type from file extension.""" + return MIME_MAP.get(path.suffix.lower(), "application/octet-stream") + + +# ------------------------------------------------------------------ # +# File processing # +# ------------------------------------------------------------------ # + + +def process_local_file(path: str | Path) -> Attachment: + """Read a local file and build content blocks. + + Raises + ------ + FileNotFoundError + If the file does not exist. + ValueError + If the file is too large or has an unsupported extension. + """ + p = Path(path).expanduser().resolve() + if not p.exists(): + raise FileNotFoundError(f"File not found: {path}") + if not p.is_file(): + raise ValueError(f"Not a file: {path}") + + size = p.stat().st_size + if size > DEFAULT_MAX_ATTACHMENT_SIZE_BYTES: + raise ValueError( + f"File too large: {size:,} bytes " + f"(max {DEFAULT_MAX_ATTACHMENT_SIZE_BYTES:,})" + ) + + ext = p.suffix.lower() + mime = detect_mime_type(p) + + if ext in IMAGE_EXTENSIONS: + blocks = _build_image_blocks(p, mime) + elif ext in AUDIO_EXTENSIONS: + blocks = _build_audio_blocks(p, ext) + elif ext in TEXT_EXTENSIONS: + blocks = _build_text_blocks(p) + else: + raise ValueError( + f"Unsupported file type: {ext}. " + f"Supported: images ({', '.join(sorted(IMAGE_EXTENSIONS))}), " + f"audio ({', '.join(sorted(AUDIO_EXTENSIONS))}), " + f"text ({', '.join(sorted(TEXT_EXTENSIONS))})" + ) + + logger.debug("Processed attachment: %s (%s, %d bytes)", p.name, mime, size) + return Attachment( + source=str(path), + content_blocks=blocks, + display_name=p.name, + size_bytes=size, + mime_type=mime, + ) + + +def _build_image_blocks(path: Path, mime: str) -> list[dict[str, Any]]: + raw = path.read_bytes() + b64 = base64.b64encode(raw).decode("ascii") + return [ + { + "type": "image_url", + "image_url": { + "url": f"data:{mime};base64,{b64}", + "detail": DEFAULT_IMAGE_DETAIL_LEVEL, + }, + } + ] + + +def _build_audio_blocks(path: Path, ext: str) -> list[dict[str, Any]]: + raw = path.read_bytes() + b64 = base64.b64encode(raw).decode("ascii") + fmt = _AUDIO_FORMAT.get(ext, "mp3") + return [{"type": "input_audio", "input_audio": {"data": b64, "format": fmt}}] + + +def _build_text_blocks(path: Path) -> list[dict[str, Any]]: + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + text = path.read_text(encoding="latin-1") + return [ + { + "type": "text", + "text": f"--- {path.name} ---\n{text}\n--- end {path.name} ---", + } + ] + + +# ------------------------------------------------------------------ # +# URL processing # +# ------------------------------------------------------------------ # + + +def process_url(url: str) -> Attachment: + """Build an image_url content block from a URL (no download).""" + return Attachment( + source=url, + content_blocks=[ + { + "type": "image_url", + "image_url": {"url": url, "detail": DEFAULT_IMAGE_DETAIL_LEVEL}, + } + ], + display_name=url.rsplit("/", 1)[-1][:60], + size_bytes=0, + mime_type="image/unknown", + ) + + +# ------------------------------------------------------------------ # +# Inline @file:path parsing # +# ------------------------------------------------------------------ # + +_INLINE_REF_RE = re.compile(r"@file:(\S+)") + + +def parse_inline_refs(text: str) -> tuple[str, list[str]]: + """Extract ``@file:path`` references from message text. + + Returns + ------- + (cleaned_text, list_of_paths) + *cleaned_text* has the ``@file:...`` tokens removed. + """ + paths = _INLINE_REF_RE.findall(text) + if not paths: + return text, [] + cleaned = _INLINE_REF_RE.sub("", text).strip() + # Collapse multiple spaces left by removal + cleaned = re.sub(r" +", " ", cleaned) + return cleaned, paths + + +# ------------------------------------------------------------------ # +# Image URL detection # +# ------------------------------------------------------------------ # + +_IMAGE_URL_RE = re.compile( + r"(https?://\S+\.(?:png|jpg|jpeg|gif|webp)(?:\?\S*)?)", + re.IGNORECASE, +) + + +def detect_image_urls(text: str) -> list[str]: + """Find image URLs in message text.""" + return _IMAGE_URL_RE.findall(text) + + +# ------------------------------------------------------------------ # +# Multi-modal content builder # +# ------------------------------------------------------------------ # + + +def build_multimodal_content( + user_text: str, + attachments: list[Attachment], + image_urls: list[str], +) -> str | list[dict[str, Any]]: + """Build the ``content`` field for a user message. + + Returns the plain string when there are no attachments or image URLs + (backward compatible). Otherwise returns a list of content blocks. + """ + if not attachments and not image_urls: + return user_text + + blocks: list[dict[str, Any]] = [] + + # Text always first + if user_text: + blocks.append({"type": "text", "text": user_text}) + + # Staged / inline attachments + for att in attachments: + blocks.extend(att.content_blocks) + + # Auto-detected image URLs (deduplicate against attachment URLs) + seen_urls: set[str] = set() + for att in attachments: + for blk in att.content_blocks: + if blk.get("type") == "image_url": + seen_urls.add(blk.get("image_url", {}).get("url", "")) + for url in image_urls: + if url not in seen_urls: + blocks.append( + { + "type": "image_url", + "image_url": {"url": url, "detail": DEFAULT_IMAGE_DETAIL_LEVEL}, + } + ) + + return blocks + + +# ------------------------------------------------------------------ # +# Dashboard attachment descriptors # +# ------------------------------------------------------------------ # + + +def _classify_kind(mime_type: str) -> str: + """Map MIME type to a UI kind: image, text, audio, or unknown.""" + if mime_type.startswith("image/"): + return "image" + if mime_type.startswith("audio/"): + return "audio" + if mime_type.startswith("text/") or mime_type in ("application/json",): + return "text" + return "unknown" + + +def attachment_descriptor( + att: Attachment, + inline_threshold: int = DEFAULT_DASHBOARD_INLINE_IMAGE_THRESHOLD, + text_preview_chars: int = DEFAULT_DASHBOARD_TEXT_PREVIEW_CHARS, +) -> dict[str, Any]: + """Build a dashboard-safe descriptor for an Attachment. + + Keeps payloads small by applying thresholds: + - Images < *inline_threshold* bytes: include data URI preview + - URL images: pass through the URL + - Text files: include first *text_preview_chars* chars + - Audio < *inline_threshold* bytes: include data URI + """ + desc: dict[str, Any] = { + "display_name": att.display_name, + "size_bytes": att.size_bytes, + "mime_type": att.mime_type, + "kind": _classify_kind(att.mime_type), + } + + for block in att.content_blocks: + btype = block.get("type") + + if btype == "image_url": + url = block["image_url"]["url"] + if url.startswith("http"): + desc["preview_url"] = url + elif att.size_bytes <= inline_threshold: + desc["preview_url"] = url + else: + desc["preview_url"] = None + + elif btype == "text": + text = block.get("text", "") + desc["text_preview"] = text[:text_preview_chars] + desc["text_truncated"] = len(text) > text_preview_chars + + elif btype == "input_audio": + if att.size_bytes <= inline_threshold: + audio_data = block["input_audio"]["data"] + audio_fmt = block["input_audio"]["format"] + mime = "audio/mpeg" if audio_fmt == "mp3" else f"audio/{audio_fmt}" + desc["audio_data_uri"] = f"data:{mime};base64,{audio_data}" + else: + desc["audio_data_uri"] = None + + return desc diff --git a/src/mcp_cli/chat/chat_context.py b/src/mcp_cli/chat/chat_context.py index 394c16ed..768c2469 100644 --- a/src/mcp_cli/chat/chat_context.py +++ b/src/mcp_cli/chat/chat_context.py @@ -139,6 +139,11 @@ def __init__( # Agent manager (set by chat_handler when multi-agent enabled, else None) self.agent_manager: Any = None + # Attachment staging for multi-modal input (/attach command) + from mcp_cli.chat.attachments import AttachmentStaging + + self.attachment_staging = AttachmentStaging() + # Tool state (filled during initialization) self.tools: list[ToolInfo] = [] self.internal_tools: list[ToolInfo] = [] @@ -653,10 +658,28 @@ async def get_server_for_tool(self, tool_name: str) -> str: return await self.tool_manager.get_server_for_tool(tool_name) or "Unknown" # ── Conversation management ─────────────────────────────────────────── - async def add_user_message(self, content: str) -> None: - """Add user message to conversation.""" - await self.session.user_says(content) - logger.debug(f"User message added: {content[:50]}...") + async def add_user_message(self, content: str | list[dict[str, Any]]) -> None: + """Add user message to conversation. + + Accepts plain text (str) or multi-modal content blocks (list[dict]). + Multi-modal content is injected as a raw event since + SessionManager.user_says() only accepts strings. + """ + if isinstance(content, str): + await self.session.user_says(content) + logger.debug(f"User message added: {content[:50]}...") + else: + # Multi-modal: inject as dict event (same pattern as inject_tool_message) + msg = HistoryMessage(role=MessageRole.USER, content=content) + event = SessionEvent( + message=msg.to_dict(), + source=EventSource.USER, + type=EventType.TOOL_CALL, + ) + self.session._session.events.append(event) + logger.debug( + f"Multi-modal user message added: {len(content)} content blocks" + ) async def add_assistant_message(self, content: str) -> None: """Add assistant message to conversation.""" diff --git a/src/mcp_cli/chat/chat_handler.py b/src/mcp_cli/chat/chat_handler.py index 74beb2ee..e1f7a2ae 100644 --- a/src/mcp_cli/chat/chat_handler.py +++ b/src/mcp_cli/chat/chat_handler.py @@ -54,6 +54,7 @@ async def handle_chat_mode( dashboard_port: int = 0, agent_id: str = "default", multi_agent: bool = False, + initial_attachments: list[str] | None = None, ) -> bool: """ Launch the interactive chat loop with streaming support. @@ -130,6 +131,18 @@ def on_progress(msg: str) -> None: output.error("Failed to initialize chat context.") return False + # Stage initial attachments from --attach CLI flag + if initial_attachments: + from mcp_cli.chat.attachments import process_local_file + + for path in initial_attachments: + try: + att = process_local_file(path) + ctx.attachment_staging.stage(att) + logger.info("Staged initial attachment: %s", att.display_name) + except (FileNotFoundError, ValueError) as exc: + output.error(f"Cannot attach {path}: {exc}") + # Update global context with initialized data await app_context.initialize() @@ -249,9 +262,9 @@ async def _dashboard_execute_tool( # Auto-save session on exit if ctx is not None and ctx.conversation_history: try: - path = ctx.save_session() - if path: - logger.info("Session auto-saved: %s", path) + saved = ctx.save_session() + if saved: + logger.info("Session auto-saved: %s", saved) except Exception as exc: logger.warning("Failed to auto-save session: %s", exc) @@ -494,12 +507,59 @@ async def _run_enhanced_chat_loop( # Normal conversation turn with streaming support if ui.verbose_mode: ui.print_user_message(user_msg) - await ctx.add_user_message(user_msg) - # Dashboard: broadcast user message + # Multi-modal processing: inline refs, staged attachments, image URLs + from mcp_cli.chat.attachments import ( + parse_inline_refs, + process_local_file, + detect_image_urls, + build_multimodal_content, + ) + + cleaned_text, inline_paths = parse_inline_refs(user_msg) + + # Process inline @file: references + inline_atts = [] + for p in inline_paths: + try: + inline_atts.append(process_local_file(p)) + except (FileNotFoundError, ValueError) as exc: + output.error(f"Cannot attach {p}: {exc}") + + # Drain staged attachments from /attach command + staged = ctx.attachment_staging.drain() + + # Detect image URLs in message text + image_urls = detect_image_urls(cleaned_text) + + # Build content (str when text-only, list[dict] when multi-modal) + content = build_multimodal_content( + cleaned_text, staged + inline_atts, image_urls + ) + + await ctx.add_user_message(content) + + # Dashboard: broadcast user message with attachment metadata if _dash := getattr(ctx, "dashboard_bridge", None): try: - await _dash.on_message("user", user_msg) + att_descriptors = None + all_atts = staged + inline_atts + if all_atts or image_urls: + from mcp_cli.chat.attachments import ( + attachment_descriptor, + process_url as _process_url, + ) + + att_descriptors = [ + attachment_descriptor(a) for a in all_atts + ] + for _url in image_urls: + att_descriptors.append( + attachment_descriptor(_process_url(_url)) + ) + await _dash.on_message( + "user", user_msg, attachments=att_descriptors + ) except Exception as _e: logger.debug("Dashboard on_message(user) error: %s", _e) diff --git a/src/mcp_cli/commands/__init__.py b/src/mcp_cli/commands/__init__.py index f751e674..58049d23 100644 --- a/src/mcp_cli/commands/__init__.py +++ b/src/mcp_cli/commands/__init__.py @@ -109,6 +109,7 @@ def register_all_commands() -> None: from mcp_cli.commands.apps import AppsCommand from mcp_cli.commands.memory import MemoryCommand from mcp_cli.commands.plan import PlanCommand + from mcp_cli.commands.attach import AttachCommand # Register basic commands registry.register(HelpCommand()) @@ -160,6 +161,9 @@ def register_all_commands() -> None: # Register plan command registry.register(PlanCommand()) + # Register attach command (multi-modal file staging) + registry.register(AttachCommand()) + # All commands have been migrated! # - tools (with subcommands: list, call, confirm) # - provider (with subcommands: list, set, show) diff --git a/src/mcp_cli/commands/attach/__init__.py b/src/mcp_cli/commands/attach/__init__.py new file mode 100644 index 00000000..b671fbd8 --- /dev/null +++ b/src/mcp_cli/commands/attach/__init__.py @@ -0,0 +1,7 @@ +"""File attachment commands.""" + +from mcp_cli.commands.attach.attach import AttachCommand + +__all__ = [ + "AttachCommand", +] diff --git a/src/mcp_cli/commands/attach/attach.py b/src/mcp_cli/commands/attach/attach.py new file mode 100644 index 00000000..79c12974 --- /dev/null +++ b/src/mcp_cli/commands/attach/attach.py @@ -0,0 +1,152 @@ +# mcp_cli/commands/attach/attach.py +"""Slash command for staging file attachments. + +Usage: + /attach [path2 ...] — stage files for next message + /attach list — show staged files + /attach clear — clear staging area +""" + +from __future__ import annotations + +from typing import Any + +from chuk_term.ui import output + +from mcp_cli.commands.base import ( + CommandMode, + CommandParameter, + CommandResult, + UnifiedCommand, +) + + +class AttachCommand(UnifiedCommand): + """Stage files for the next chat message.""" + + @property + def name(self) -> str: + return "attach" + + @property + def aliases(self) -> list[str]: + return ["file", "image"] + + @property + def description(self) -> str: + return "Attach files (images, text, audio) to the next message" + + @property + def help_text(self) -> str: + return ( + "/attach [path2 ...] — stage files for next message\n" + "/attach list — show staged files\n" + "/attach clear — clear staging area\n" + "\n" + "Aliases: /file, /image\n" + "\n" + "Supported types:\n" + " Images: .png .jpg .jpeg .gif .webp\n" + " Audio: .mp3 .wav\n" + " Text: .py .js .ts .txt .md .csv .json .html .xml .yaml .yml\n" + " .sh .bash .rs .go .java .c .cpp .h .hpp .rb .swift\n" + " .kt .sql .toml .ini .cfg .env .log .jsx .tsx\n" + ) + + @property + def modes(self) -> CommandMode: + return CommandMode.CHAT + + @property + def parameters(self) -> list[CommandParameter]: + return [ + CommandParameter( + name="args", + type=str, + required=False, + help="File path(s) or subcommand (list, clear)", + ), + ] + + async def execute(self, **kwargs: Any) -> CommandResult: + """Execute the /attach command.""" + chat_context = kwargs.get("chat_context") + if not chat_context: + return CommandResult( + success=False, + error="Attach command requires chat context.", + ) + + # Parse arguments + args_val = kwargs.get("args", "") + if isinstance(args_val, list): + args_str = " ".join(str(a) for a in args_val) + else: + args_str = str(args_val).strip() if args_val else "" + + if not args_str: + return self._show_usage() + + parts = args_str.split() + action = parts[0].lower() + + # Subcommands + if action == "list": + return self._list_staged(chat_context) + if action == "clear": + return self._clear_staged(chat_context) + + # Treat all parts as file paths to stage + return self._stage_files(chat_context, parts) + + # ── Subcommand handlers ────────────────────────────────────────────── + + def _show_usage(self) -> CommandResult: + output.info("Usage: /attach [path2 ...] | list | clear") + return CommandResult(success=True) + + def _list_staged(self, ctx: Any) -> CommandResult: + staged = ctx.attachment_staging.peek() + if not staged: + output.info("No attachments staged.") + return CommandResult(success=True, output="No attachments staged.") + + lines = [] + for att in staged: + size_kb = att.size_bytes / 1024 + lines.append(f" {att.display_name} ({att.mime_type}, {size_kb:.1f} KB)") + summary = f"{len(staged)} staged attachment(s):\n" + "\n".join(lines) + output.info(summary) + return CommandResult(success=True, output=summary) + + def _clear_staged(self, ctx: Any) -> CommandResult: + count = ctx.attachment_staging.count + ctx.attachment_staging.clear() + msg = f"Cleared {count} attachment(s)." if count else "Nothing to clear." + output.info(msg) + return CommandResult(success=True, output=msg) + + def _stage_files(self, ctx: Any, paths: list[str]) -> CommandResult: + from mcp_cli.chat.attachments import process_local_file + + staged_names: list[str] = [] + errors: list[str] = [] + + for path in paths: + try: + att = process_local_file(path) + ctx.attachment_staging.stage(att) + staged_names.append(att.display_name) + output.success(f"Staged: {att.display_name} ({att.mime_type})") + except (FileNotFoundError, ValueError) as exc: + errors.append(f"{path}: {exc}") + output.error(f"Cannot attach {path}: {exc}") + + if errors and not staged_names: + return CommandResult(success=False, error="; ".join(errors)) + + total = ctx.attachment_staging.count + msg = f"Staged {len(staged_names)} file(s). Total pending: {total}" + if errors: + msg += f" ({len(errors)} failed)" + return CommandResult(success=True, output=msg) diff --git a/src/mcp_cli/config/defaults.py b/src/mcp_cli/config/defaults.py index f88518cd..a6ed2e08 100644 --- a/src/mcp_cli/config/defaults.py +++ b/src/mcp_cli/config/defaults.py @@ -412,3 +412,23 @@ DEFAULT_LOG_BACKUP_COUNT = 3 """Number of rotated log files to keep.""" + + +# ================================================================ +# Attachment Defaults +# ================================================================ + +DEFAULT_MAX_ATTACHMENT_SIZE_BYTES = 20_971_520 +"""Maximum attachment file size (20 MB). Base64 encoding adds ~33%.""" + +DEFAULT_MAX_ATTACHMENTS_PER_MESSAGE = 10 +"""Maximum attachments per user message (staged + inline combined).""" + +DEFAULT_IMAGE_DETAIL_LEVEL = "auto" +"""Default detail level for image_url content blocks (auto, low, high).""" + +DEFAULT_DASHBOARD_INLINE_IMAGE_THRESHOLD = 102_400 +"""Max base64 size (bytes) for inline image previews in dashboard (100 KB).""" + +DEFAULT_DASHBOARD_TEXT_PREVIEW_CHARS = 2000 +"""Max chars of text file content to send as preview in dashboard.""" diff --git a/src/mcp_cli/dashboard/bridge.py b/src/mcp_cli/dashboard/bridge.py index d47ea9ef..3b5776c7 100644 --- a/src/mcp_cli/dashboard/bridge.py +++ b/src/mcp_cli/dashboard/bridge.py @@ -189,6 +189,7 @@ async def on_message( streaming: bool = False, reasoning: str | None = None, tool_calls: list[dict[str, Any]] | None = None, + attachments: list[dict[str, Any]] | None = None, ) -> None: """Called when a complete conversation message is emitted.""" payload: dict[str, Any] = { @@ -198,6 +199,7 @@ async def on_message( "streaming": streaming, "tool_calls": tool_calls, "reasoning": reasoning, + "attachments": attachments, } await self._broadcast(_envelope("CONVERSATION_MESSAGE", payload)) @@ -787,6 +789,69 @@ def _build_config_state(self) -> dict[str, Any] | None: logger.debug("Error building CONFIG_STATE: %s", exc) return None + @staticmethod + def _content_block_to_descriptor(block: dict[str, Any]) -> dict[str, Any]: + """Convert a raw content block dict into a dashboard-safe descriptor. + + Used during conversation history replay when we have raw content + blocks but not ``Attachment`` objects. + """ + from mcp_cli.config.defaults import ( + DEFAULT_DASHBOARD_INLINE_IMAGE_THRESHOLD, + DEFAULT_DASHBOARD_TEXT_PREVIEW_CHARS, + ) + + btype = block.get("type", "") + desc: dict[str, Any] = {"kind": "unknown", "display_name": "", "size_bytes": 0} + + if btype == "image_url": + url = block.get("image_url", {}).get("url", "") + desc["kind"] = "image" + desc["mime_type"] = "image/unknown" + if url.startswith("http"): + desc["preview_url"] = url + desc["display_name"] = url.rsplit("/", 1)[-1][:60] + elif url.startswith("data:"): + # Estimate raw size from data URI length (base64 ≈ 4/3 of raw) + data_part = url.split(",", 1)[-1] if "," in url else "" + est_size = len(data_part) * 3 // 4 + if est_size <= DEFAULT_DASHBOARD_INLINE_IMAGE_THRESHOLD: + desc["preview_url"] = url + else: + desc["preview_url"] = None + desc["size_bytes"] = est_size + desc["display_name"] = "image" + else: + desc["preview_url"] = None + + elif btype == "text": + text = block.get("text", "") + desc["kind"] = "text" + desc["mime_type"] = "text/plain" + desc["text_preview"] = text[:DEFAULT_DASHBOARD_TEXT_PREVIEW_CHARS] + desc["text_truncated"] = len(text) > DEFAULT_DASHBOARD_TEXT_PREVIEW_CHARS + # Try to extract filename from "--- filename ---" wrapper + if text.startswith("--- ") and " ---\n" in text: + name = text[4 : text.index(" ---\n")] + desc["display_name"] = name + + elif btype == "input_audio": + audio = block.get("input_audio", {}) + fmt = audio.get("format", "mp3") + data = audio.get("data", "") + est_size = len(data) * 3 // 4 + desc["kind"] = "audio" + desc["mime_type"] = "audio/mpeg" if fmt == "mp3" else f"audio/{fmt}" + desc["size_bytes"] = est_size + desc["display_name"] = f"audio.{fmt}" + if est_size <= DEFAULT_DASHBOARD_INLINE_IMAGE_THRESHOLD: + mime = desc["mime_type"] + desc["audio_data_uri"] = f"data:{mime};base64,{data}" + else: + desc["audio_data_uri"] = None + + return desc + def _build_conversation_history(self) -> list[dict[str, Any]] | None: """Build conversation history payload from ChatContext. @@ -805,12 +870,34 @@ def _build_conversation_history(self) -> list[dict[str, Any]] | None: # tool results belong in the activity stream, not chat if role in ("system", "tool"): continue + + raw_content = d.get("content") + attachments = None + + # Handle multimodal content blocks + if isinstance(raw_content, list): + text_parts: list[str] = [] + att_descriptors: list[dict[str, Any]] = [] + for block in raw_content: + if block.get("type") == "text": + text_parts.append(block.get("text", "")) + else: + att_descriptors.append( + self._content_block_to_descriptor(block) + ) + content_str = "\n".join(text_parts) + if att_descriptors: + attachments = att_descriptors + else: + content_str = raw_content or "" + messages.append( { "role": role, - "content": d.get("content") or "", + "content": content_str, "tool_calls": d.get("tool_calls"), "reasoning": d.get("reasoning_content"), + "attachments": attachments, } ) return messages if messages else None @@ -841,6 +928,32 @@ def _build_activity_history(self) -> list[dict[str, Any]] | None: events: list[dict[str, Any]] = [] for d in raw: role = d.get("role", "") + + # User messages with attachments (multimodal content) + if role == "user": + raw_content = d.get("content") + if isinstance(raw_content, list): + text_parts: list[str] = [] + att_descs: list[dict[str, Any]] = [] + for block in raw_content: + if block.get("type") == "text": + text_parts.append(block.get("text", "")) + else: + att_descs.append( + self._content_block_to_descriptor(block) + ) + if att_descs: + events.append( + { + "type": "CONVERSATION_MESSAGE", + "payload": { + "role": "user", + "content": "\n".join(text_parts), + "attachments": att_descs, + }, + } + ) + if role == "assistant": tool_calls = d.get("tool_calls") or [] reasoning = d.get("reasoning_content") diff --git a/src/mcp_cli/dashboard/static/views/activity-stream.html b/src/mcp_cli/dashboard/static/views/activity-stream.html index a5b5b0ee..769b59bc 100644 --- a/src/mcp_cli/dashboard/static/views/activity-stream.html +++ b/src/mcp_cli/dashboard/static/views/activity-stream.html @@ -649,6 +649,26 @@ return card; } + // User messages with attachments + if (role === 'user' && p.attachments && p.attachments.length > 0) { + card.className = 'event-card user-att'; + card.dataset.evId = ev.id; + const attNames = p.attachments.map(a => a.display_name || '?').join(', '); + const totalSize = p.attachments.reduce((s, a) => s + (a.size_bytes || 0), 0); + const contentPreview = (p.content || '').slice(0, 80); + card.innerHTML = ` +
    + ${agentBadgeHtml(p.agent_id)} + 📎 Attachments: ${esc(attNames)} + ${_formatBytes(totalSize)} + ${tsStr} +
    +
    + ${esc(contentPreview)} +
    `; + return card; + } + // Skip other message types (user input, plain assistant text without reasoning) // Return an empty non-rendered card card.style.display = 'none'; @@ -686,6 +706,13 @@ return String(s).replace(/&/g,'&').replace(//g,'>'); } +function _formatBytes(bytes) { + if (!bytes || bytes === 0) return ''; + if (bytes < 1024) return bytes + 'B'; + if (bytes < 1048576) return (bytes / 1024).toFixed(1) + 'KB'; + return (bytes / 1048576).toFixed(1) + 'MB'; +} + function trimDom() { // Keep at most VIRT_THRESHOLD cards in the DOM const cards = eventsEl.querySelectorAll('.event-card'); diff --git a/src/mcp_cli/dashboard/static/views/agent-terminal.html b/src/mcp_cli/dashboard/static/views/agent-terminal.html index 6c79ee9b..82bb5bea 100644 --- a/src/mcp_cli/dashboard/static/views/agent-terminal.html +++ b/src/mcp_cli/dashboard/static/views/agent-terminal.html @@ -277,6 +277,36 @@ flex-direction: column; overflow: hidden; } + +/* ── Attachment UI ─────────────────────────────────────────────── */ +.attachment-strip { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; } +.att-badge { + display: inline-flex; align-items: center; gap: 4px; + background: var(--dash-bg); border: 1px solid var(--dash-border); + border-radius: 4px; padding: 3px 8px; font-size: 11px; + color: var(--dash-fg-muted); cursor: default; +} +.att-badge .att-icon { font-size: 13px; } +.att-badge .att-name { max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.att-badge .att-size { font-size: 10px; opacity: 0.7; } +.att-image-preview { + max-width: 200px; max-height: 150px; border-radius: var(--dash-radius); + border: 1px solid var(--dash-border); margin-top: 4px; cursor: pointer; +} +.att-image-preview:hover { opacity: 0.85; } +.att-text-preview { + background: var(--dash-bg); border: 1px solid var(--dash-border); + border-radius: var(--dash-radius); padding: 6px 8px; margin-top: 4px; + font-family: var(--dash-font-mono); font-size: 11px; white-space: pre-wrap; + max-height: 80px; overflow: hidden; cursor: pointer; position: relative; +} +.att-text-preview.expanded { max-height: none; } +.att-text-preview .att-text-fade { + position: absolute; bottom: 0; left: 0; right: 0; height: 30px; + background: linear-gradient(transparent, var(--dash-bg)); pointer-events: none; +} +.att-text-preview.expanded .att-text-fade { display: none; } +.att-audio-player { margin-top: 4px; width: 100%; max-width: 280px; height: 32px; } @@ -425,9 +455,15 @@ return; // tool_calls shown as cards; content usually empty } - if (!content) return; + if (!content && !(payload.attachments && payload.attachments.length)) return; + + const bubble = addMessageBubble(role, content || '', streaming); + + // Render attachment badges / previews if present + if (payload.attachments && payload.attachments.length > 0) { + renderAttachments(bubble, payload.attachments); + } - const bubble = addMessageBubble(role, content, streaming); if (streaming) { streamingBubble = bubble; streamingContent = content || ''; @@ -758,6 +794,62 @@ searchInput.value = ''; } +// ── Attachment rendering ────────────────────────────────────────────── +function formatBytes(bytes) { + if (!bytes || bytes === 0) return ''; + if (bytes < 1024) return bytes + 'B'; + if (bytes < 1048576) return (bytes / 1024).toFixed(1) + 'KB'; + return (bytes / 1048576).toFixed(1) + 'MB'; +} + +function renderAttachments(bubble, attachments) { + const strip = document.createElement('div'); + strip.className = 'attachment-strip'; + for (const att of attachments) { + const kind = att.kind || 'unknown'; + const icon = kind === 'image' ? '\uD83D\uDDBC\uFE0F' : + kind === 'audio' ? '\uD83C\uDFB5' : + kind === 'text' ? '\uD83D\uDCC4' : '\uD83D\uDCCE'; + const badge = document.createElement('div'); + badge.className = 'att-badge'; + badge.innerHTML = '' + icon + '' + + '' + esc(att.display_name || '') + '' + + '' + formatBytes(att.size_bytes) + ''; + strip.appendChild(badge); + + if (kind === 'image' && att.preview_url) { + const img = document.createElement('img'); + img.className = 'att-image-preview'; + img.src = att.preview_url; + img.alt = att.display_name || 'image'; + img.loading = 'lazy'; + img.addEventListener('click', function() { window.open(att.preview_url, '_blank'); }); + strip.appendChild(img); + } + if (kind === 'text' && att.text_preview) { + const pre = document.createElement('div'); + pre.className = 'att-text-preview'; + pre.textContent = att.text_preview; + if (att.text_truncated) { + const fade = document.createElement('div'); + fade.className = 'att-text-fade'; + pre.appendChild(fade); + } + pre.addEventListener('click', function() { pre.classList.toggle('expanded'); }); + strip.appendChild(pre); + } + if (kind === 'audio' && att.audio_data_uri) { + const audio = document.createElement('audio'); + audio.className = 'att-audio-player'; + audio.controls = true; + audio.src = att.audio_data_uri; + strip.appendChild(audio); + } + } + const contentEl = bubble.querySelector('.msg-content'); + if (contentEl) { contentEl.after(strip); } else { bubble.appendChild(strip); } +} + // Register hljs languages if available if (typeof hljs !== 'undefined') { hljs.registerLanguage && void 0; // already done via script tags diff --git a/src/mcp_cli/main.py b/src/mcp_cli/main.py index 113eb558..0d8c1ef7 100644 --- a/src/mcp_cli/main.py +++ b/src/mcp_cli/main.py @@ -170,6 +170,11 @@ def main_callback( "--multi-agent", help="Enable multi-agent orchestration tools (agent_spawn, agent_stop, etc.). Implies --dashboard.", ), + attach: list[str] | None = typer.Option( + None, + "--attach", + help="Attach files to the first message (repeatable: --attach img.png --attach code.py).", + ), ) -> None: """MCP CLI - If no subcommand is given, start chat mode.""" @@ -401,6 +406,7 @@ async def _start_chat(): no_browser=no_browser, dashboard_port=dashboard_port, multi_agent=multi_agent, + initial_attachments=attach, ) logger.debug(f"Chat mode completed with success: {success}") except asyncio.TimeoutError: @@ -525,6 +531,11 @@ def _chat_command( "--multi-agent", help="Enable multi-agent orchestration tools (agent_spawn, agent_stop, etc.). Implies --dashboard.", ), + attach: list[str] | None = typer.Option( + None, + "--attach", + help="Attach files to the first message (repeatable: --attach img.png --attach code.py).", + ), ) -> None: """Start chat mode (same as default behavior without subcommand).""" # Re-configure logging based on user options @@ -664,6 +675,7 @@ async def _start_chat(): no_browser=no_browser, dashboard_port=dashboard_port, multi_agent=multi_agent, + initial_attachments=attach, ) logger.debug(f"Chat mode completed with success: {success}") except asyncio.TimeoutError: diff --git a/tests/chat/test_attachments.py b/tests/chat/test_attachments.py new file mode 100644 index 00000000..608e9294 --- /dev/null +++ b/tests/chat/test_attachments.py @@ -0,0 +1,480 @@ +# tests/chat/test_attachments.py +"""Unit tests for multi-modal attachment processing.""" + +from __future__ import annotations + +import pytest + +from mcp_cli.chat.attachments import ( + AUDIO_EXTENSIONS, + IMAGE_EXTENSIONS, + TEXT_EXTENSIONS, + Attachment, + AttachmentStaging, + _classify_kind, + attachment_descriptor, + build_multimodal_content, + detect_image_urls, + detect_mime_type, + parse_inline_refs, + process_local_file, + process_url, +) +from mcp_cli.config.defaults import DEFAULT_MAX_ATTACHMENT_SIZE_BYTES + + +# --------------------------------------------------------------------------- +# MIME detection +# --------------------------------------------------------------------------- + + +class TestDetectMimeType: + def test_png(self, tmp_path): + from pathlib import Path + + assert detect_mime_type(Path("test.png")) == "image/png" + + def test_jpg(self, tmp_path): + from pathlib import Path + + assert detect_mime_type(Path("photo.jpg")) == "image/jpeg" + + def test_jpeg(self): + from pathlib import Path + + assert detect_mime_type(Path("photo.jpeg")) == "image/jpeg" + + def test_mp3(self): + from pathlib import Path + + assert detect_mime_type(Path("audio.mp3")) == "audio/mpeg" + + def test_python(self): + from pathlib import Path + + assert detect_mime_type(Path("code.py")) == "text/plain" + + def test_csv(self): + from pathlib import Path + + assert detect_mime_type(Path("data.csv")) == "text/csv" + + def test_unknown(self): + from pathlib import Path + + assert detect_mime_type(Path("file.xyz")) == "application/octet-stream" + + +# --------------------------------------------------------------------------- +# Inline @file: parsing +# --------------------------------------------------------------------------- + + +class TestParseInlineRefs: + def test_single_ref(self): + text, paths = parse_inline_refs("Look at @file:img.png please") + assert paths == ["img.png"] + assert "@file" not in text + assert "Look at" in text + assert "please" in text + + def test_multiple_refs(self): + text, paths = parse_inline_refs("Compare @file:a.png and @file:b.jpg") + assert len(paths) == 2 + assert "a.png" in paths + assert "b.jpg" in paths + assert "@file" not in text + + def test_no_refs(self): + text, paths = parse_inline_refs("No refs here") + assert paths == [] + assert text == "No refs here" + + def test_path_with_directory(self): + text, paths = parse_inline_refs("See @file:/tmp/dir/photo.png") + assert paths == ["/tmp/dir/photo.png"] + + def test_empty_string(self): + text, paths = parse_inline_refs("") + assert paths == [] + assert text == "" + + def test_collapses_spaces(self): + text, _ = parse_inline_refs("before @file:x.png after") + # Should not have double spaces + assert " " not in text + + +# --------------------------------------------------------------------------- +# Image URL detection +# --------------------------------------------------------------------------- + + +class TestDetectImageUrls: + def test_png_url(self): + urls = detect_image_urls("Look at https://example.com/cat.png") + assert urls == ["https://example.com/cat.png"] + + def test_jpg_url(self): + urls = detect_image_urls("https://example.com/photo.jpg is nice") + assert urls == ["https://example.com/photo.jpg"] + + def test_case_insensitive(self): + urls = detect_image_urls("https://example.com/cat.PNG") + assert len(urls) == 1 + + def test_url_with_query(self): + urls = detect_image_urls("https://example.com/img.png?size=large") + assert len(urls) == 1 + assert "?size=large" in urls[0] + + def test_no_urls(self): + urls = detect_image_urls("Just plain text here") + assert urls == [] + + def test_non_image_url(self): + urls = detect_image_urls("Visit https://example.com/page.html") + assert urls == [] + + def test_multiple_urls(self): + urls = detect_image_urls("https://a.com/1.png and https://b.com/2.jpg") + assert len(urls) == 2 + + +# --------------------------------------------------------------------------- +# AttachmentStaging +# --------------------------------------------------------------------------- + + +class TestAttachmentStaging: + def _make_att(self, name: str = "test.png") -> Attachment: + return Attachment( + source=name, + content_blocks=[{"type": "text", "text": "mock"}], + display_name=name, + size_bytes=100, + mime_type="image/png", + ) + + def test_stage_and_count(self): + staging = AttachmentStaging() + assert staging.count == 0 + staging.stage(self._make_att()) + assert staging.count == 1 + + def test_drain_returns_and_clears(self): + staging = AttachmentStaging() + staging.stage(self._make_att("a.png")) + staging.stage(self._make_att("b.png")) + drained = staging.drain() + assert len(drained) == 2 + assert staging.count == 0 + + def test_peek_does_not_clear(self): + staging = AttachmentStaging() + staging.stage(self._make_att()) + peeked = staging.peek() + assert len(peeked) == 1 + assert staging.count == 1 + + def test_clear(self): + staging = AttachmentStaging() + staging.stage(self._make_att()) + staging.clear() + assert staging.count == 0 + + def test_drain_empty(self): + staging = AttachmentStaging() + assert staging.drain() == [] + + +# --------------------------------------------------------------------------- +# process_local_file +# --------------------------------------------------------------------------- + + +class TestProcessLocalFile: + def test_image_file(self, tmp_path): + img = tmp_path / "test.png" + img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100) + att = process_local_file(str(img)) + assert att.mime_type == "image/png" + assert att.display_name == "test.png" + assert att.size_bytes > 0 + assert len(att.content_blocks) == 1 + block = att.content_blocks[0] + assert block["type"] == "image_url" + assert block["image_url"]["url"].startswith("data:image/png;base64,") + + def test_text_file(self, tmp_path): + txt = tmp_path / "code.py" + txt.write_text("print('hello')") + att = process_local_file(str(txt)) + assert att.mime_type == "text/plain" + assert len(att.content_blocks) == 1 + block = att.content_blocks[0] + assert block["type"] == "text" + assert "print('hello')" in block["text"] + assert "--- code.py ---" in block["text"] + + def test_audio_file(self, tmp_path): + audio = tmp_path / "clip.mp3" + audio.write_bytes(b"\xff\xfb\x90\x00" + b"\x00" * 50) + att = process_local_file(str(audio)) + assert att.mime_type == "audio/mpeg" + block = att.content_blocks[0] + assert block["type"] == "input_audio" + assert block["input_audio"]["format"] == "mp3" + + def test_wav_audio(self, tmp_path): + audio = tmp_path / "clip.wav" + audio.write_bytes(b"RIFF" + b"\x00" * 50) + att = process_local_file(str(audio)) + block = att.content_blocks[0] + assert block["input_audio"]["format"] == "wav" + + def test_csv_file(self, tmp_path): + csv = tmp_path / "data.csv" + csv.write_text("a,b,c\n1,2,3") + att = process_local_file(str(csv)) + assert att.mime_type == "text/csv" + assert "a,b,c" in att.content_blocks[0]["text"] + + def test_file_not_found(self): + with pytest.raises(FileNotFoundError): + process_local_file("/nonexistent/file.png") + + def test_not_a_file(self, tmp_path): + with pytest.raises(ValueError, match="Not a file"): + process_local_file(str(tmp_path)) + + def test_too_large(self, tmp_path): + big = tmp_path / "huge.png" + big.write_bytes(b"\x00" * (DEFAULT_MAX_ATTACHMENT_SIZE_BYTES + 1)) + with pytest.raises(ValueError, match="too large"): + process_local_file(str(big)) + + def test_unsupported_extension(self, tmp_path): + f = tmp_path / "data.xyz" + f.write_bytes(b"\x00" * 10) + with pytest.raises(ValueError, match="Unsupported"): + process_local_file(str(f)) + + def test_tilde_expansion(self, tmp_path, monkeypatch): + # Just verify expanduser is called (don't rely on actual ~) + f = tmp_path / "test.txt" + f.write_text("content") + att = process_local_file(str(f)) + assert att.display_name == "test.txt" + + def test_json_file(self, tmp_path): + f = tmp_path / "config.json" + f.write_text('{"key": "value"}') + att = process_local_file(str(f)) + assert att.mime_type == "application/json" + assert '{"key": "value"}' in att.content_blocks[0]["text"] + + +# --------------------------------------------------------------------------- +# process_url +# --------------------------------------------------------------------------- + + +class TestProcessUrl: + def test_basic_url(self): + att = process_url("https://example.com/photo.jpg") + assert att.source == "https://example.com/photo.jpg" + assert att.size_bytes == 0 + block = att.content_blocks[0] + assert block["type"] == "image_url" + assert block["image_url"]["url"] == "https://example.com/photo.jpg" + + def test_display_name(self): + att = process_url("https://example.com/path/to/image.png") + assert att.display_name == "image.png" + + +# --------------------------------------------------------------------------- +# build_multimodal_content +# --------------------------------------------------------------------------- + + +class TestBuildMultimodalContent: + def test_plain_text_passthrough(self): + result = build_multimodal_content("hello", [], []) + assert result == "hello" + assert isinstance(result, str) + + def test_with_attachment(self): + att = Attachment( + source="test.png", + content_blocks=[ + {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}} + ], + display_name="test.png", + size_bytes=100, + mime_type="image/png", + ) + result = build_multimodal_content("describe this", [att], []) + assert isinstance(result, list) + assert len(result) == 2 + assert result[0] == {"type": "text", "text": "describe this"} + assert result[1]["type"] == "image_url" + + def test_with_image_url(self): + result = build_multimodal_content( + "what is this", [], ["https://ex.com/img.png"] + ) + assert isinstance(result, list) + assert len(result) == 2 + assert result[1]["type"] == "image_url" + assert result[1]["image_url"]["url"] == "https://ex.com/img.png" + + def test_dedup_urls(self): + """Image URLs already in attachments are not duplicated.""" + att = Attachment( + source="https://ex.com/img.png", + content_blocks=[ + {"type": "image_url", "image_url": {"url": "https://ex.com/img.png"}} + ], + display_name="img.png", + size_bytes=0, + mime_type="image/png", + ) + result = build_multimodal_content("look", [att], ["https://ex.com/img.png"]) + assert isinstance(result, list) + # Should have text + 1 image (not 2) + image_blocks = [b for b in result if b.get("type") == "image_url"] + assert len(image_blocks) == 1 + + def test_empty_text_with_attachment(self): + att = Attachment( + source="test.png", + content_blocks=[{"type": "image_url", "image_url": {"url": "data:..."}}], + display_name="test.png", + size_bytes=100, + mime_type="image/png", + ) + result = build_multimodal_content("", [att], []) + assert isinstance(result, list) + # No text block when text is empty + text_blocks = [b for b in result if b.get("type") == "text"] + assert len(text_blocks) == 0 + + def test_multiple_attachments(self): + atts = [ + Attachment( + source=f"f{i}.png", + content_blocks=[ + {"type": "image_url", "image_url": {"url": f"data:{i}"}} + ], + display_name=f"f{i}.png", + size_bytes=100, + mime_type="image/png", + ) + for i in range(3) + ] + result = build_multimodal_content("compare these", atts, []) + assert isinstance(result, list) + assert len(result) == 4 # 1 text + 3 images + + +# --------------------------------------------------------------------------- +# Extension sets +# --------------------------------------------------------------------------- + + +class TestExtensionSets: + def test_no_overlap(self): + assert IMAGE_EXTENSIONS & AUDIO_EXTENSIONS == set() + assert IMAGE_EXTENSIONS & TEXT_EXTENSIONS == set() + assert AUDIO_EXTENSIONS & TEXT_EXTENSIONS == set() + + def test_common_extensions_covered(self): + assert ".png" in IMAGE_EXTENSIONS + assert ".jpg" in IMAGE_EXTENSIONS + assert ".mp3" in AUDIO_EXTENSIONS + assert ".py" in TEXT_EXTENSIONS + assert ".txt" in TEXT_EXTENSIONS + + +# --------------------------------------------------------------------------- +# _classify_kind +# --------------------------------------------------------------------------- + + +class TestClassifyKind: + def test_image(self): + assert _classify_kind("image/png") == "image" + assert _classify_kind("image/jpeg") == "image" + + def test_audio(self): + assert _classify_kind("audio/mpeg") == "audio" + assert _classify_kind("audio/wav") == "audio" + + def test_text(self): + assert _classify_kind("text/plain") == "text" + assert _classify_kind("text/csv") == "text" + + def test_json_is_text(self): + assert _classify_kind("application/json") == "text" + + def test_unknown(self): + assert _classify_kind("application/octet-stream") == "unknown" + + +# --------------------------------------------------------------------------- +# attachment_descriptor +# --------------------------------------------------------------------------- + + +class TestAttachmentDescriptor: + def test_small_image(self, tmp_path): + img = tmp_path / "small.png" + img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 50) + att = process_local_file(str(img)) + desc = attachment_descriptor(att) + assert desc["kind"] == "image" + assert desc["display_name"] == "small.png" + assert desc["preview_url"] is not None + assert desc["preview_url"].startswith("data:image/png;base64,") + + def test_large_image(self, tmp_path): + img = tmp_path / "big.png" + # Write >100KB to exceed threshold + img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 110_000) + att = process_local_file(str(img)) + desc = attachment_descriptor(att) + assert desc["kind"] == "image" + assert desc["preview_url"] is None + + def test_url_image(self): + att = process_url("https://example.com/photo.jpg") + desc = attachment_descriptor(att) + assert desc["kind"] == "image" + assert desc["preview_url"] == "https://example.com/photo.jpg" + + def test_text_file(self, tmp_path): + f = tmp_path / "code.py" + f.write_text("x = 42\n" * 100) + att = process_local_file(str(f)) + desc = attachment_descriptor(att) + assert desc["kind"] == "text" + assert "x = 42" in desc["text_preview"] + assert isinstance(desc["text_truncated"], bool) + + def test_text_file_truncation(self, tmp_path): + f = tmp_path / "big.txt" + f.write_text("A" * 5000) + att = process_local_file(str(f)) + desc = attachment_descriptor(att, text_preview_chars=100) + assert len(desc["text_preview"]) == 100 + assert desc["text_truncated"] is True + + def test_audio(self, tmp_path): + audio = tmp_path / "clip.mp3" + audio.write_bytes(b"\xff\xfb\x90\x00" + b"\x00" * 50) + att = process_local_file(str(audio)) + desc = attachment_descriptor(att) + assert desc["kind"] == "audio" + assert desc["audio_data_uri"].startswith("data:audio/mpeg;base64,") diff --git a/tests/chat/test_chat_handler_coverage.py b/tests/chat/test_chat_handler_coverage.py index 46bdbce0..61263053 100644 --- a/tests/chat/test_chat_handler_coverage.py +++ b/tests/chat/test_chat_handler_coverage.py @@ -41,6 +41,10 @@ def _make_ctx(exit_requested=False): ctx.model = "gpt-4" ctx.exit_requested = exit_requested ctx.add_user_message = AsyncMock() + # Real staging so multi-modal processing works correctly (drain returns []) + from mcp_cli.chat.attachments import AttachmentStaging + + ctx.attachment_staging = AttachmentStaging() return ctx diff --git a/tests/commands/attach/__init__.py b/tests/commands/attach/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/commands/attach/test_attach_command.py b/tests/commands/attach/test_attach_command.py new file mode 100644 index 00000000..b2a10df6 --- /dev/null +++ b/tests/commands/attach/test_attach_command.py @@ -0,0 +1,221 @@ +# tests/commands/attach/test_attach_command.py +"""Unit tests for the /attach slash command.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from mcp_cli.chat.attachments import AttachmentStaging +from mcp_cli.commands.attach.attach import AttachCommand +from mcp_cli.commands.base import CommandMode + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def cmd(): + return AttachCommand() + + +@pytest.fixture +def ctx(): + """Mock ChatContext with a real AttachmentStaging.""" + mock = MagicMock() + mock.attachment_staging = AttachmentStaging() + return mock + + +# --------------------------------------------------------------------------- +# Properties +# --------------------------------------------------------------------------- + + +class TestAttachCommandProperties: + def test_name(self, cmd): + assert cmd.name == "attach" + + def test_aliases(self, cmd): + assert "file" in cmd.aliases + assert "image" in cmd.aliases + + def test_modes(self, cmd): + assert cmd.modes == CommandMode.CHAT + + def test_description(self, cmd): + assert "attach" in cmd.description.lower() or "file" in cmd.description.lower() + + def test_help_text(self, cmd): + assert "/attach" in cmd.help_text + assert ".png" in cmd.help_text + + +# --------------------------------------------------------------------------- +# Execute: no context +# --------------------------------------------------------------------------- + + +class TestAttachNoContext: + @pytest.mark.asyncio + async def test_no_context(self, cmd): + result = await cmd.execute() + assert not result.success + assert "context" in result.error.lower() + + +# --------------------------------------------------------------------------- +# Execute: staging files +# --------------------------------------------------------------------------- + + +class TestAttachStageFiles: + @pytest.mark.asyncio + async def test_stage_image(self, cmd, ctx, tmp_path): + img = tmp_path / "photo.png" + img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 50) + + with patch("chuk_term.ui.output"): + result = await cmd.execute(chat_context=ctx, args=str(img)) + + assert result.success + assert ctx.attachment_staging.count == 1 + + @pytest.mark.asyncio + async def test_stage_text_file(self, cmd, ctx, tmp_path): + txt = tmp_path / "code.py" + txt.write_text("print('hello')") + + with patch("chuk_term.ui.output"): + result = await cmd.execute(chat_context=ctx, args=str(txt)) + + assert result.success + assert ctx.attachment_staging.count == 1 + + @pytest.mark.asyncio + async def test_stage_multiple_files(self, cmd, ctx, tmp_path): + f1 = tmp_path / "a.txt" + f2 = tmp_path / "b.txt" + f1.write_text("alpha") + f2.write_text("beta") + + with patch("chuk_term.ui.output"): + result = await cmd.execute(chat_context=ctx, args=f"{f1} {f2}") + + assert result.success + assert ctx.attachment_staging.count == 2 + + @pytest.mark.asyncio + async def test_stage_missing_file(self, cmd, ctx): + with patch("chuk_term.ui.output"): + result = await cmd.execute(chat_context=ctx, args="/nonexistent/file.png") + + assert not result.success + assert ctx.attachment_staging.count == 0 + + @pytest.mark.asyncio + async def test_stage_partial_failure(self, cmd, ctx, tmp_path): + good = tmp_path / "ok.txt" + good.write_text("good file") + + with patch("chuk_term.ui.output"): + result = await cmd.execute( + chat_context=ctx, args=f"{good} /nonexistent/bad.png" + ) + + # Partially successful + assert result.success + assert ctx.attachment_staging.count == 1 + assert "1 failed" in result.output + + @pytest.mark.asyncio + async def test_args_as_list(self, cmd, ctx, tmp_path): + f = tmp_path / "data.csv" + f.write_text("a,b\n1,2") + + with patch("chuk_term.ui.output"): + result = await cmd.execute(chat_context=ctx, args=[str(f)]) + + assert result.success + assert ctx.attachment_staging.count == 1 + + +# --------------------------------------------------------------------------- +# Execute: list subcommand +# --------------------------------------------------------------------------- + + +class TestAttachList: + @pytest.mark.asyncio + async def test_list_empty(self, cmd, ctx): + with patch("chuk_term.ui.output"): + result = await cmd.execute(chat_context=ctx, args="list") + + assert result.success + assert "no" in result.output.lower() or "0" in result.output + + @pytest.mark.asyncio + async def test_list_with_staged(self, cmd, ctx, tmp_path): + f = tmp_path / "photo.png" + f.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 50) + + with patch("chuk_term.ui.output"): + await cmd.execute(chat_context=ctx, args=str(f)) + result = await cmd.execute(chat_context=ctx, args="list") + + assert result.success + assert "photo.png" in result.output + + +# --------------------------------------------------------------------------- +# Execute: clear subcommand +# --------------------------------------------------------------------------- + + +class TestAttachClear: + @pytest.mark.asyncio + async def test_clear_empty(self, cmd, ctx): + with patch("chuk_term.ui.output"): + result = await cmd.execute(chat_context=ctx, args="clear") + + assert result.success + assert "nothing" in result.output.lower() or "0" in result.output.lower() + + @pytest.mark.asyncio + async def test_clear_with_staged(self, cmd, ctx, tmp_path): + f = tmp_path / "test.txt" + f.write_text("hello") + + with patch("chuk_term.ui.output"): + await cmd.execute(chat_context=ctx, args=str(f)) + assert ctx.attachment_staging.count == 1 + + result = await cmd.execute(chat_context=ctx, args="clear") + + assert result.success + assert ctx.attachment_staging.count == 0 + assert "1" in result.output + + +# --------------------------------------------------------------------------- +# Execute: no args (usage) +# --------------------------------------------------------------------------- + + +class TestAttachUsage: + @pytest.mark.asyncio + async def test_no_args_shows_usage(self, cmd, ctx): + with patch("chuk_term.ui.output"): + result = await cmd.execute(chat_context=ctx, args="") + + assert result.success + + @pytest.mark.asyncio + async def test_none_args_shows_usage(self, cmd, ctx): + with patch("chuk_term.ui.output"): + result = await cmd.execute(chat_context=ctx, args=None) + + assert result.success diff --git a/tests/dashboard/test_bridge_extended.py b/tests/dashboard/test_bridge_extended.py index c6871f52..336e7092 100644 --- a/tests/dashboard/test_bridge_extended.py +++ b/tests/dashboard/test_bridge_extended.py @@ -539,6 +539,138 @@ def test_multiple_tool_calls_in_one_message(self): assert names == {"tool_a", "tool_b"} +# --------------------------------------------------------------------------- +# Attachment support +# --------------------------------------------------------------------------- + + +class TestOnMessageWithAttachments: + """Verify on_message passes attachments through to payload.""" + + @pytest.mark.asyncio + async def test_with_attachments(self): + bridge, server = _make_bridge() + atts = [ + {"display_name": "photo.png", "kind": "image", "preview_url": "data:..."} + ] + await bridge.on_message("user", "describe this", attachments=atts) + msg = server.broadcast.call_args[0][0] + assert msg["payload"]["attachments"] == atts + assert msg["payload"]["content"] == "describe this" + + @pytest.mark.asyncio + async def test_no_attachments_backward_compat(self): + bridge, server = _make_bridge() + await bridge.on_message("user", "hello") + msg = server.broadcast.call_args[0][0] + assert msg["payload"]["attachments"] is None + + +class TestConversationHistoryWithAttachments: + """Verify multimodal user messages produce attachments in history.""" + + def _make_ctx_with_history(self, messages): + class FakeMsg: + def __init__(self, d): + self._d = d + + def to_dict(self): + return self._d + + ctx = MagicMock() + ctx.conversation_history = [FakeMsg(m) for m in messages] + return ctx + + def test_multimodal_user_message(self): + bridge, _ = _make_bridge() + bridge.set_context( + self._make_ctx_with_history( + [ + { + "role": "user", + "content": [ + {"type": "text", "text": "describe this image"}, + { + "type": "image_url", + "image_url": {"url": "https://example.com/cat.png"}, + }, + ], + }, + {"role": "assistant", "content": "It's a cat!"}, + ] + ) + ) + history = bridge._build_conversation_history() + assert len(history) == 2 + user_msg = history[0] + assert user_msg["content"] == "describe this image" + assert user_msg["attachments"] is not None + assert len(user_msg["attachments"]) == 1 + assert user_msg["attachments"][0]["kind"] == "image" + # Assistant message has no attachments + assert history[1]["attachments"] is None + + def test_text_only_no_attachments(self): + bridge, _ = _make_bridge() + bridge.set_context( + self._make_ctx_with_history([{"role": "user", "content": "hello"}]) + ) + history = bridge._build_conversation_history() + assert history[0]["attachments"] is None + + +class TestActivityHistoryWithAttachments: + """Verify user attachment events appear in activity history.""" + + def _make_ctx_with_history(self, messages): + class FakeMsg: + def __init__(self, d): + self._d = d + + def to_dict(self): + return self._d + + ctx = MagicMock() + ctx.conversation_history = [FakeMsg(m) for m in messages] + return ctx + + def test_user_attachments_in_activity(self): + bridge, _ = _make_bridge() + bridge.set_context( + self._make_ctx_with_history( + [ + { + "role": "user", + "content": [ + {"type": "text", "text": "look at this"}, + { + "type": "image_url", + "image_url": {"url": "https://example.com/img.png"}, + }, + ], + }, + ] + ) + ) + events = bridge._build_activity_history() + assert events is not None + assert len(events) == 1 + assert events[0]["payload"]["role"] == "user" + assert events[0]["payload"]["attachments"] is not None + + def test_no_attachments_no_user_event(self): + bridge, _ = _make_bridge() + bridge.set_context( + self._make_ctx_with_history( + [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi"}, + ] + ) + ) + assert bridge._build_activity_history() is None + + # --------------------------------------------------------------------------- # agent_id plumbing # --------------------------------------------------------------------------- From 4da7473b4d1cf6b930e7757ce9221aabe436b21d Mon Sep 17 00:00:00 2001 From: chrishayuk Date: Sat, 28 Feb 2026 23:45:37 +0000 Subject: [PATCH 4/7] improving multi-modal --- src/mcp_cli/chat/attachments.py | 107 +++++++++-- src/mcp_cli/dashboard/bridge.py | 19 ++ src/mcp_cli/dashboard/server.py | 1 + src/mcp_cli/dashboard/static/shell.html | 2 +- .../static/views/agent-terminal.html | 168 +++++++++++++++++- tests/chat/test_attachments.py | 49 +++++ tests/dashboard/test_bridge_extended.py | 92 ++++++++++ 7 files changed, 423 insertions(+), 15 deletions(-) diff --git a/src/mcp_cli/chat/attachments.py b/src/mcp_cli/chat/attachments.py index 6fe9dcc1..ad812ce5 100644 --- a/src/mcp_cli/chat/attachments.py +++ b/src/mcp_cli/chat/attachments.py @@ -191,9 +191,8 @@ def process_local_file(path: str | Path) -> Attachment: ) -def _build_image_blocks(path: Path, mime: str) -> list[dict[str, Any]]: - raw = path.read_bytes() - b64 = base64.b64encode(raw).decode("ascii") +def _image_blocks_from_b64(b64: str, mime: str) -> list[dict[str, Any]]: + """Build image content blocks from base64-encoded data.""" return [ { "type": "image_url", @@ -205,11 +204,32 @@ def _build_image_blocks(path: Path, mime: str) -> list[dict[str, Any]]: ] +def _audio_blocks_from_b64(b64: str, ext: str) -> list[dict[str, Any]]: + """Build audio content blocks from base64-encoded data.""" + fmt = _AUDIO_FORMAT.get(ext, "mp3") + return [{"type": "input_audio", "input_audio": {"data": b64, "format": fmt}}] + + +def _text_blocks_from_string(text: str, label: str) -> list[dict[str, Any]]: + """Build text content blocks from a string.""" + return [ + { + "type": "text", + "text": f"--- {label} ---\n{text}\n--- end {label} ---", + } + ] + + +def _build_image_blocks(path: Path, mime: str) -> list[dict[str, Any]]: + raw = path.read_bytes() + b64 = base64.b64encode(raw).decode("ascii") + return _image_blocks_from_b64(b64, mime) + + def _build_audio_blocks(path: Path, ext: str) -> list[dict[str, Any]]: raw = path.read_bytes() b64 = base64.b64encode(raw).decode("ascii") - fmt = _AUDIO_FORMAT.get(ext, "mp3") - return [{"type": "input_audio", "input_audio": {"data": b64, "format": fmt}}] + return _audio_blocks_from_b64(b64, ext) def _build_text_blocks(path: Path) -> list[dict[str, Any]]: @@ -217,12 +237,77 @@ def _build_text_blocks(path: Path) -> list[dict[str, Any]]: text = path.read_text(encoding="utf-8") except UnicodeDecodeError: text = path.read_text(encoding="latin-1") - return [ - { - "type": "text", - "text": f"--- {path.name} ---\n{text}\n--- end {path.name} ---", - } - ] + return _text_blocks_from_string(text, path.name) + + +# ------------------------------------------------------------------ # +# Browser file processing # +# ------------------------------------------------------------------ # + + +def process_browser_file( + filename: str, + data_b64: str, + mime_type: str, +) -> Attachment: + """Build an ``Attachment`` from browser-uploaded file data. + + Parameters + ---------- + filename: + Original filename from the browser ``File`` object. + data_b64: + Base64-encoded file contents. + mime_type: + MIME type reported by the browser (e.g. ``image/png``). + + Raises + ------ + ValueError + If the file is too large or has an unsupported extension. + """ + raw = base64.b64decode(data_b64) + size = len(raw) + if size > DEFAULT_MAX_ATTACHMENT_SIZE_BYTES: + raise ValueError( + f"File too large: {size:,} bytes " + f"(max {DEFAULT_MAX_ATTACHMENT_SIZE_BYTES:,})" + ) + + ext = Path(filename).suffix.lower() + # Fall back to provided mime_type if extension not in our map + mime = MIME_MAP.get(ext, mime_type) + + if ext in IMAGE_EXTENSIONS: + b64 = base64.b64encode(raw).decode("ascii") + blocks = _image_blocks_from_b64(b64, mime) + elif ext in AUDIO_EXTENSIONS: + b64 = base64.b64encode(raw).decode("ascii") + blocks = _audio_blocks_from_b64(b64, ext) + elif ext in TEXT_EXTENSIONS: + try: + text = raw.decode("utf-8") + except UnicodeDecodeError: + text = raw.decode("latin-1") + blocks = _text_blocks_from_string(text, filename) + else: + raise ValueError( + f"Unsupported file type: {ext or '(none)'}. " + f"Supported: images ({', '.join(sorted(IMAGE_EXTENSIONS))}), " + f"audio ({', '.join(sorted(AUDIO_EXTENSIONS))}), " + f"text ({', '.join(sorted(TEXT_EXTENSIONS))})" + ) + + logger.debug( + "Processed browser attachment: %s (%s, %d bytes)", filename, mime, size + ) + return Attachment( + source=f"browser:{filename}", + content_blocks=blocks, + display_name=filename, + size_bytes=size, + mime_type=mime, + ) # ------------------------------------------------------------------ # diff --git a/src/mcp_cli/dashboard/bridge.py b/src/mcp_cli/dashboard/bridge.py index 3b5776c7..1afab2b4 100644 --- a/src/mcp_cli/dashboard/bridge.py +++ b/src/mcp_cli/dashboard/bridge.py @@ -300,6 +300,25 @@ async def _on_browser_message(self, msg: dict[str, Any]) -> None: msg_type = msg.get("type") if msg_type in ("USER_MESSAGE", "USER_COMMAND"): content = msg.get("content") or msg.get("command", "") + files = msg.get("files") # list of {name, data, mime_type} + + # Stage browser-uploaded files on ChatContext + if files and self._ctx is not None: + from mcp_cli.chat.attachments import process_browser_file + + for f in files: + try: + att = process_browser_file(f["name"], f["data"], f["mime_type"]) + self._ctx.attachment_staging.stage(att) + except (ValueError, KeyError) as exc: + logger.warning( + "Bad browser file %s: %s", f.get("name", "?"), exc + ) + + # If files attached but no text, queue a space so the chat loop iterates + if not content and files: + content = " " + if content and self._input_queue is not None: try: await self._input_queue.put(content) diff --git a/src/mcp_cli/dashboard/server.py b/src/mcp_cli/dashboard/server.py index 67b13918..3746ad11 100644 --- a/src/mcp_cli/dashboard/server.py +++ b/src/mcp_cli/dashboard/server.py @@ -72,6 +72,7 @@ async def start(self, port: int = 0) -> int: "localhost", bound_port, process_request=self._process_request, + max_size=25 * 1024 * 1024, # 25 MB for file attachments ) logger.info("Dashboard server started on port %d", bound_port) return bound_port diff --git a/src/mcp_cli/dashboard/static/shell.html b/src/mcp_cli/dashboard/static/shell.html index 47f92f25..be278448 100644 --- a/src/mcp_cli/dashboard/static/shell.html +++ b/src/mcp_cli/dashboard/static/shell.html @@ -1141,7 +1141,7 @@

    Tool Confirmation Required

    sendToBridge({ type: 'REQUEST_TOOL', view_id: viewId, ...msg.payload }); break; case 'USER_MESSAGE': - sendToBridge({ type: 'USER_MESSAGE', content: msg.payload.content }); + sendToBridge({ type: 'USER_MESSAGE', content: msg.payload.content, files: msg.payload.files || undefined }); break; case 'USER_COMMAND': sendToBridge({ type: 'USER_COMMAND', command: msg.payload.command }); diff --git a/src/mcp_cli/dashboard/static/views/agent-terminal.html b/src/mcp_cli/dashboard/static/views/agent-terminal.html index 82bb5bea..a506a284 100644 --- a/src/mcp_cli/dashboard/static/views/agent-terminal.html +++ b/src/mcp_cli/dashboard/static/views/agent-terminal.html @@ -268,6 +268,69 @@ } #send-btn:hover { opacity: 0.9; } +/* ── Attach button & staging ─────────────────────────────────── */ +#attach-btn { + background: none; + border: 1px solid var(--dash-border); + border-radius: 50%; + width: 32px; + height: 32px; + font-size: 18px; + cursor: pointer; + color: var(--dash-fg); + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; +} +#attach-btn:hover { background: var(--dash-bg-hover); } + +#staging-strip { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 0 12px; +} +#staging-strip:empty { display: none; } +.staged-file { + display: inline-flex; + align-items: center; + gap: 4px; + background: var(--dash-bg-hover); + border-radius: 12px; + padding: 4px 10px; + font-size: 12px; + color: var(--dash-fg); +} +.staged-file .remove-staged { + cursor: pointer; + opacity: 0.6; + margin-left: 2px; +} +.staged-file .remove-staged:hover { opacity: 1; } +.staged-thumb { + width: 32px; + height: 32px; + object-fit: cover; + border-radius: 4px; +} + +#drop-overlay { + display: none; + position: absolute; + inset: 0; + z-index: 100; + background: rgba(0, 0, 0, 0.6); + color: #fff; + font-size: 24px; + align-items: center; + justify-content: center; + border-radius: 8px; + border: 3px dashed var(--dash-accent); + pointer-events: none; +} + /* Scroll container — must be flex to constrain #messages height */ #messages-wrapper { flex: 1; @@ -328,10 +391,14 @@
    +
    + +
    +
    Drop files here