From c9b5b55623e28455f7b273cdab7381933fb61097 Mon Sep 17 00:00:00 2001
From: shravya maddipudi
Date: Thu, 28 May 2026 18:17:04 -0500
Subject: [PATCH 01/29] fix(session): add BaseAgent session-model compatibility
methods
---
code_puppy/agents/base_agent.py | 24 +++++++++++++++++++
tests/agents/test_base_agent_configuration.py | 21 ++++++++++++++++
2 files changed, 45 insertions(+)
diff --git a/code_puppy/agents/base_agent.py b/code_puppy/agents/base_agent.py
index ff433ccee..4576819d1 100644
--- a/code_puppy/agents/base_agent.py
+++ b/code_puppy/agents/base_agent.py
@@ -73,6 +73,7 @@ def __init__(self) -> None:
self._code_generation_agent: Any = None
self._last_model_name: Optional[str] = None
self._runtime_model_name_override: Optional[str] = None
+ self._session_model_name: Optional[str] = None
self._puppy_rules: Optional[str] = None
self._mcp_servers: List[Any] = []
self.cur_model: Optional[pydantic_ai.models.Model] = None
@@ -143,6 +144,8 @@ def get_model_name(self) -> Optional[str]:
override = self.get_runtime_model_name_override()
if override:
return override
+ if self._session_model_name:
+ return self._session_model_name
pinned = get_agent_pinned_model(self.name)
return pinned if pinned else get_global_model_name()
@@ -191,6 +194,27 @@ def clear_message_history(self) -> None:
def append_to_message_history(self, message: Any) -> None:
self._message_history.append(message)
+ # ---- Session model + compaction compatibility helpers ----------------
+ def set_session_model(self, model_name: Optional[str]) -> None:
+ """Set a per-session model override for this agent instance."""
+ self._session_model_name = model_name or None
+
+ def get_session_model(self) -> Optional[str]:
+ """Return the per-session model override, if any."""
+ return self._session_model_name
+
+ def reset_session_model(self) -> None:
+ """Clear the per-session model override for this agent instance."""
+ self._session_model_name = None
+
+ def get_compacted_message_hashes(self) -> Set[int]:
+ """Expose compacted-message hashes for session state transfers."""
+ return set(self._compacted_message_hashes)
+
+ def add_compacted_message_hash(self, message_hash: int) -> None:
+ """Track a compacted-message hash for this agent instance."""
+ self._compacted_message_hashes.add(message_hash)
+
# ---- Token / context helpers ------------------------------------------
def estimate_tokens_for_message(self, message: Any) -> int:
return estimate_tokens_for_message(message, self.get_model_name())
diff --git a/tests/agents/test_base_agent_configuration.py b/tests/agents/test_base_agent_configuration.py
index 7aae7f460..550b7f91f 100644
--- a/tests/agents/test_base_agent_configuration.py
+++ b/tests/agents/test_base_agent_configuration.py
@@ -41,3 +41,24 @@ def test_non_reasoning_sections_unchanged(self, agent):
"Continue autonomously",
]:
assert expected in prompt, f"Missing prompt section: {expected}"
+
+
+class TestBaseAgentSessionModelCompatibility:
+ @pytest.fixture
+ def agent(self):
+ return CodePuppyAgent()
+
+ def test_session_model_override_roundtrip(self, agent):
+ original = agent.get_model_name()
+ agent.set_session_model("gpt-5.5")
+ assert agent.get_session_model() == "gpt-5.5"
+ assert agent.get_model_name() == "gpt-5.5"
+ agent.reset_session_model()
+ assert agent.get_session_model() is None
+ assert agent.get_model_name() == original
+
+ def test_compacted_hash_compatibility_helpers(self, agent):
+ assert agent.get_compacted_message_hashes() == set()
+ agent.add_compacted_message_hash(123)
+ agent.add_compacted_message_hash(123)
+ assert agent.get_compacted_message_hashes() == {123}
From 04316b65484f71abdc8816abcf3d1c42433e3e6b Mon Sep 17 00:00:00 2001
From: shravya maddipudi
Date: Thu, 28 May 2026 18:17:12 -0500
Subject: [PATCH 02/29] fix(ws): avoid cwd history corruption and harden config
schema endpoint
---
code_puppy/api/routers/config.py | 211 +
.../api/tests/test_config_router_schema.py | 17 +
code_puppy/api/ws/chat_handler.py | 3898 +++++++++++++++++
3 files changed, 4126 insertions(+)
create mode 100644 code_puppy/api/routers/config.py
create mode 100644 code_puppy/api/tests/test_config_router_schema.py
create mode 100644 code_puppy/api/ws/chat_handler.py
diff --git a/code_puppy/api/routers/config.py b/code_puppy/api/routers/config.py
new file mode 100644
index 000000000..d92778b7f
--- /dev/null
+++ b/code_puppy/api/routers/config.py
@@ -0,0 +1,211 @@
+"""Configuration management API endpoints."""
+
+from typing import Any, Dict, List
+
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel
+
+router = APIRouter()
+
+
+class ConfigValue(BaseModel):
+ key: str
+ value: Any
+
+
+class ConfigUpdate(BaseModel):
+ value: Any
+
+
+@router.get("/")
+async def list_config() -> Dict[str, Any]:
+ """List all configuration keys and their current values."""
+ from code_puppy.config import get_config_keys, get_value
+
+ config = {}
+ for key in get_config_keys():
+ config[key] = get_value(key)
+ return {"config": config}
+
+
+@router.get("/keys")
+async def get_config_keys_list() -> List[str]:
+ """Get list of all valid configuration keys."""
+ from code_puppy.config import get_config_keys
+
+ return get_config_keys()
+
+
+@router.get("/schema")
+async def get_config_schema() -> Dict[str, Any]:
+ """Get configuration schema with metadata for all config keys.
+
+ Returns metadata including:
+ - description: Human-readable description of the config key
+ - category: Config category (core, behavior, model, advanced, experimental)
+ - type: Data type (boolean, string, number, choice)
+ - choices: Valid values (for choice type)
+ - default: Default value
+
+ This endpoint allows frontends to dynamically render config UIs
+ without hardcoding config key metadata.
+
+ Returns:
+ Dict with 'keys' mapping config names to their metadata
+ """
+ try:
+ from code_puppy.config import CONFIG_SCHEMA
+
+ return {"keys": CONFIG_SCHEMA}
+ except Exception:
+ # Migration-compat fallback: synthesize a minimal schema so the
+ # endpoint remains functional even if CONFIG_SCHEMA is not exported.
+ from code_puppy.config import get_config_keys, get_value
+
+ inferred: Dict[str, Dict[str, Any]] = {}
+ for key in get_config_keys():
+ value = get_value(key)
+ inferred[key] = {
+ "description": "",
+ "category": "legacy",
+ "type": type(value).__name__ if value is not None else "unknown",
+ "default": value,
+ }
+ return {"keys": inferred}
+
+
+@router.get("/{key}")
+async def get_config_value(key: str) -> ConfigValue:
+ """Get a specific configuration value."""
+ from code_puppy.config import get_config_keys, get_value
+
+ valid_keys = get_config_keys()
+ if key not in valid_keys:
+ raise HTTPException(
+ 404, f"Config key '{key}' not found. Valid keys: {valid_keys}"
+ )
+
+ value = get_value(key)
+ return ConfigValue(key=key, value=value)
+
+
+@router.put("/{key}")
+async def set_config_value(key: str, update: ConfigUpdate) -> ConfigValue:
+ """Set a configuration value."""
+ from code_puppy.config import get_config_keys, get_value, set_value
+
+ valid_keys = get_config_keys()
+ if key not in valid_keys:
+ raise HTTPException(
+ 404, f"Config key '{key}' not found. Valid keys: {valid_keys}"
+ )
+
+ set_value(key, str(update.value))
+ return ConfigValue(key=key, value=get_value(key))
+
+
+@router.delete("/{key}")
+async def reset_config_value(key: str) -> Dict[str, str]:
+ """Reset a configuration value to default (remove from config file)."""
+ from code_puppy.config import reset_value
+
+ reset_value(key)
+ return {"message": f"Config key '{key}' reset to default"}
+
+
+# =============================================================================
+# Model Settings Endpoints
+# =============================================================================
+
+
+class ModelSettingsResponse(BaseModel):
+ """Response containing model-specific settings."""
+
+ model_name: str
+ settings: Dict[str, Any]
+
+
+class ModelSettingsUpdateRequest(BaseModel):
+ """Request to update a model setting."""
+
+ setting: str
+ value: Any
+
+
+@router.get("/model_settings/{model_name}")
+async def get_model_settings(model_name: str) -> ModelSettingsResponse:
+ """Get all settings for a specific model.
+
+ Returns the persisted settings from ~/.code_puppy/code_puppy.ini
+ for the specified model name.
+
+ Args:
+ model_name: The model name (e.g., 'gpt-5-2-0125', 'claude-4-5-sonnet')
+
+ Returns:
+ ModelSettingsResponse with model name and settings dict
+ """
+ from code_puppy.config import get_all_model_settings
+
+ settings = get_all_model_settings(model_name)
+ return ModelSettingsResponse(model_name=model_name, settings=settings)
+
+
+@router.put("/model_settings/{model_name}")
+async def update_model_setting(
+ model_name: str, request: ModelSettingsUpdateRequest
+) -> ModelSettingsResponse:
+ """Update a specific setting for a model.
+
+ Persists the setting to ~/.code_puppy/code_puppy.ini
+
+ Args:
+ model_name: The model name
+ request: ModelSettingsUpdateRequest with setting name and value
+
+ Returns:
+ Updated ModelSettingsResponse
+ """
+ from code_puppy.config import get_all_model_settings, set_model_setting
+
+ set_model_setting(model_name, request.setting, request.value)
+ settings = get_all_model_settings(model_name)
+ return ModelSettingsResponse(model_name=model_name, settings=settings)
+
+
+@router.delete("/model_settings/{model_name}")
+async def clear_model_settings(model_name: str) -> Dict[str, str]:
+ """Clear all settings for a specific model.
+
+ Removes all per-model settings, reverting to defaults.
+
+ Args:
+ model_name: The model name
+
+ Returns:
+ Success message
+ """
+ from code_puppy.config import clear_model_settings
+
+ clear_model_settings(model_name)
+ return {"message": f"All settings cleared for model '{model_name}'"}
+
+
+@router.delete("/model_settings/{model_name}/{setting}")
+async def clear_model_setting(model_name: str, setting: str) -> ModelSettingsResponse:
+ """Clear a specific setting for a model.
+
+ Removes the setting, reverting it to the default.
+
+ Args:
+ model_name: The model name
+ setting: The setting name to clear
+
+ Returns:
+ Updated ModelSettingsResponse
+ """
+ from code_puppy.config import get_all_model_settings, set_model_setting
+
+ set_model_setting(model_name, setting, None)
+ settings = get_all_model_settings(model_name)
+ return ModelSettingsResponse(model_name=model_name, settings=settings)
diff --git a/code_puppy/api/tests/test_config_router_schema.py b/code_puppy/api/tests/test_config_router_schema.py
new file mode 100644
index 000000000..96c97176a
--- /dev/null
+++ b/code_puppy/api/tests/test_config_router_schema.py
@@ -0,0 +1,17 @@
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+from code_puppy.api.routers import config as config_router
+
+
+def test_config_schema_endpoint_returns_mapping_without_config_schema_symbol():
+ app = FastAPI()
+ app.include_router(config_router.router, prefix="/config")
+
+ with TestClient(app) as client:
+ resp = client.get("/config/schema")
+
+ assert resp.status_code == 200
+ payload = resp.json()
+ assert "keys" in payload
+ assert isinstance(payload["keys"], dict)
diff --git a/code_puppy/api/ws/chat_handler.py b/code_puppy/api/ws/chat_handler.py
new file mode 100644
index 000000000..fbcb4c918
--- /dev/null
+++ b/code_puppy/api/ws/chat_handler.py
@@ -0,0 +1,3898 @@
+"""WebSocket endpoint for interactive chat with the Code Puppy agent.
+
+This is the largest and most complex WebSocket handler, responsible for:
+- Interactive chat sessions with streaming responses
+- Session management (create, restore, switch)
+- Tool call/result forwarding
+- File attachment processing
+- Slash command handling
+- Permission request/response flow
+- Working directory management
+- Real-time event streaming from the agent
+
+NOTE: This handler was extracted from the monolithic websocket.py to improve
+maintainability. The internal structure is preserved to avoid regressions.
+Future refactoring should break down the message handling loop further.
+"""
+
+import asyncio
+import datetime
+import json
+import logging
+import re
+import uuid
+from pathlib import Path
+from typing import Any
+
+from fastapi import FastAPI, WebSocket, WebSocketDisconnect
+from pydantic import TypeAdapter, ValidationError
+
+from code_puppy.api.db.queries import (
+ update_session_working_directory,
+ write_error_message_to_sqlite,
+ write_system_message_to_sqlite,
+ write_turn_to_sqlite,
+)
+from code_puppy.api.error_parser import parse_api_error as _legacy_parse_api_error
+from code_puppy.api.session_context import _validate_session_id, session_manager
+from code_puppy.api.ws.attachments import build_file_context_and_attachments
+from code_puppy.api.ws.background_save import (
+ fire_and_track,
+ save_agent_result_in_background,
+)
+from code_puppy.api.ws.connection_manager import connection_manager
+from code_puppy.api.ws.schemas import (
+ PROTOCOL_VERSION,
+ ClientMessage,
+ ServerAgentInvoked,
+ ServerAssistantMessageDelta,
+ ServerAssistantMessageEnd,
+ ServerAssistantMessageStart,
+ ServerCancelled,
+ ServerCommandResult,
+ ServerConfigValue,
+ ServerError,
+ ServerMessage,
+ ServerResponse,
+ ServerSessionMetaUpdated,
+ ServerSessionRestored,
+ ServerSessionSwitched,
+ ServerStatus,
+ ServerStreamEnd,
+ ServerSystem,
+ ServerToolCall,
+ ServerToolResult,
+ ServerUserMessage,
+ ServerWorkingDirectoryChanged,
+)
+from code_puppy.config import get_global_model_name
+from code_puppy.messaging.bus import get_message_bus
+from code_puppy.model_errors import normalize_model_error as _normalize_model_error
+from code_puppy.session_storage import generate_heuristic_title
+from code_puppy.tools.command_runner import (
+ cleanup_session_process_tracking,
+ init_session_process_tracking,
+)
+
+# Import session context for desk-puppy working directory support
+try:
+ from code_puppy.plugins.walmart_specific.session_context import (
+ clear_session_working_directory,
+ set_session_working_directory,
+ )
+
+ HAS_SESSION_CONTEXT = True
+except ImportError:
+ HAS_SESSION_CONTEXT = False
+
+ def set_session_working_directory(d):
+ pass
+
+ def clear_session_working_directory():
+ pass
+
+
+_ClientMessageAdapter = TypeAdapter(ClientMessage)
+
+logger = logging.getLogger(__name__)
+
+# === TOOL-CALL-PARITY BRANCH LOADED ===
+logger.warning(
+ "🐕 CHAT_HANDLER LOADED FROM TOOL-CALL-PARITY BRANCH - ToolReturnPart fix active!"
+)
+print("🐕 CHAT_HANDLER LOADED FROM TOOL-CALL-PARITY BRANCH", flush=True)
+
+
+# ---------------------------------------------------------------------------
+# WebSocket error frame helpers (see fix/orphaned-tool-use-id-history)
+# ---------------------------------------------------------------------------
+
+_UNKNOWN_ERROR_TYPE = "unknown_error"
+_TOOL_HISTORY_ERROR_TYPE = "tool_history_error"
+
+_CODE_TO_ERROR_TYPE: dict = {
+ "rate_limit_or_overloaded": "rate_limit",
+ "backend_unavailable": "server_error",
+ "auth_error": "auth_error",
+ "quota_exceeded": "quota_exceeded",
+ "content_blocked": "content_blocked",
+ "invalid_tool_history": _TOOL_HISTORY_ERROR_TYPE,
+}
+
+
+def parse_api_error(exc: Exception) -> dict:
+ """Convert an agent-run exception into a structured frontend error dict.
+
+ Wraps ``normalize_model_error`` for primary classification (including the
+ new ``invalid_tool_history`` code for Anthropic HTTP 400 tool-mismatch
+ errors), falling back to the legacy ``_legacy_parse_api_error`` for all
+ other categories so existing behaviour is unchanged.
+
+ Returns:
+ Dict with keys: user_message, error_type, technical_details, action_required.
+ """
+ norm = _normalize_model_error(exc)
+ if norm.code in _CODE_TO_ERROR_TYPE:
+ error_type = _CODE_TO_ERROR_TYPE[norm.code]
+ user_message = norm.user_message or str(exc)
+ action_required = error_type in ("rate_limit", "quota_exceeded")
+ return {
+ "user_message": user_message,
+ "error_type": error_type,
+ "technical_details": repr(exc),
+ "action_required": action_required,
+ }
+ # Fall back to legacy parser for unclassified errors
+ return _legacy_parse_api_error(exc)
+
+
+def has_streamed_content(collected_text) -> bool:
+ """Return True when the client has already received streaming output chunks.
+
+ Args:
+ collected_text: List of text chunks accumulated during the agent run.
+ Elements may be None or empty strings.
+
+ Returns:
+ True if at least one chunk has substantive (non-whitespace) content.
+ """
+ return any((chunk or "").strip() for chunk in collected_text)
+
+
+def build_error_response_frames(
+ agent_error: Exception,
+ collected_text,
+ session_id: str,
+) -> list:
+ """Build the ordered WebSocket frames to send when an agent error occurs.
+
+ When streaming output was already delivered to the client, a ``stream_end``
+ frame (``success=False``) is prepended so the frontend can exit its
+ streaming state before processing the error frame. Without this handshake
+ the frontend hangs indefinitely waiting for a ``stream_end`` that never
+ arrives.
+
+ Args:
+ agent_error: The exception raised by the agent run.
+ collected_text: Accumulated streaming chunks from the current turn.
+ session_id: The WebSocket session identifier.
+
+ Returns:
+ List of JSON-serialisable dicts to send to the client in order.
+ """
+ frames: list[dict] = []
+ if has_streamed_content(collected_text):
+ frames.append(
+ ServerStreamEnd(
+ success=False,
+ session_id=session_id,
+ ).model_dump(exclude_none=True)
+ )
+ parsed = parse_api_error(agent_error)
+ frames.append(
+ ServerError(
+ error=parsed["user_message"],
+ error_type=parsed["error_type"],
+ technical_details=parsed["technical_details"],
+ action_required=parsed.get("action_required"),
+ session_id=session_id,
+ ).model_dump(exclude_none=True)
+ )
+ return frames
+
+
+def register_chat_endpoint(app: FastAPI) -> None:
+ """Register the /ws/chat WebSocket endpoint."""
+
+ @app.websocket("/ws/chat")
+ async def websocket_chat(
+ websocket: WebSocket, session_id: str | None = None
+ ) -> None:
+ """Interactive chat with the Code Puppy agent.
+
+ Protocol:
+ Client sends:
+ {"type": "message", "content": "your message here"}
+
+ Server sends:
+ # Streaming message events (all include agent_name, model_name, tool_name metadata)
+ {"type": "assistant_message_start", "message_id": "...", "part_type": "text|thinking",
+ "agent_name": "...", "model_name": "...", "tool_name": "..." or null}
+ {"type": "assistant_message_delta", "message_id": "...", "content": "...",
+ "agent_name": "...", "model_name": "...", "tool_name": "..." or null}
+ {"type": "assistant_message_end", "message_id": "...", "full_content": "...",
+ "agent_name": "...", "model_name": "...", "tool_name": "..." or null}
+
+ # Tool call events (include agent_name, model_name metadata)
+ {"type": "tool_call", "tool_name": "...", "args": {...},
+ "agent_name": "...", "model_name": "..."}
+ {"type": "tool_result", "tool_name": "...", "result": "...", "success": true,
+ "agent_name": "...", "model_name": "..."}
+
+ # Final response
+ {"type": "response", "content": "...", "done": true,
+ "agent_name": "...", "model_name": "...", "tokens": {...}}
+
+ # Errors
+ {"type": "error", "error": "..."}
+
+ Query Parameters:
+ session_id: Optional session ID to resume. If not provided, a new session is created.
+ Example: /ws/chat?session_id=WS_session_20260115_143022
+ """
+ await websocket.accept()
+ logger.debug(
+ "Chat WebSocket client connected (session_id param: %s)", session_id
+ )
+
+ # Flag to track if WebSocket is still open
+ ws_closed = False
+
+ async def persist_error_payload(data: dict[str, Any]) -> None:
+ """Persist structured error frames so they survive reloads."""
+ if data.get("type") != "error" or not session_id:
+ return
+
+ try:
+ await write_error_message_to_sqlite(
+ session_id=session_id,
+ error=str(data.get("error") or "An unknown error occurred"),
+ error_type=str(data.get("error_type") or "unknown"),
+ technical_details=str(data.get("technical_details") or ""),
+ action_required=data.get("action_required"),
+ agent_name=(ctx.agent_name if ctx else ""),
+ model_name=(ctx.model_name if ctx else ""),
+ timestamp=datetime.datetime.now(datetime.timezone.utc).isoformat(),
+ )
+ except Exception:
+ logger.warning(
+ "[WS:%s] Failed to persist error payload to SQLite",
+ session_id,
+ exc_info=True,
+ )
+
+ async def safe_send_json(data: dict) -> bool:
+ """Safely send JSON to WebSocket, returns False if connection is closed."""
+ nonlocal ws_closed
+ if ws_closed:
+ logger.debug(
+ "[WS:%s] safe_send_json skipped (ws_closed=True): type=%s",
+ session_id,
+ data.get("type"),
+ )
+ return False
+
+ msg_type = data.get("type")
+ if msg_type in {
+ "error",
+ "response",
+ "assistant_message_start",
+ "assistant_message_end",
+ }:
+ # Keep this low-noise but useful for tracing request lifecycle
+ logger.debug(
+ "[WS:%s] → send_json type=%s keys=%s",
+ session_id,
+ msg_type,
+ sorted(list(data.keys())),
+ )
+ if msg_type == "error":
+ logger.debug(
+ "[WS:%s] error payload: error_type=%r action_required=%r error=%r",
+ session_id,
+ data.get("error_type"),
+ data.get("action_required"),
+ (data.get("error") or "")[:300],
+ )
+
+ try:
+ if msg_type == "error":
+ await persist_error_payload(data)
+ await websocket.send_json(data)
+ return True
+ except Exception as e:
+ logger.warning(
+ "[WS:%s] send_json failed for type=%s: %s",
+ session_id,
+ msg_type,
+ e,
+ exc_info=True,
+ )
+ if "close message" in str(e).lower() or "closed" in str(e).lower():
+ ws_closed = True
+ logger.debug("WebSocket closed, stopping sends")
+ return False
+
+ async def send_typed(msg: ServerMessage) -> bool:
+ """Send a typed protocol message to the client."""
+ return await safe_send_json(msg.model_dump(exclude_none=True))
+
+ async def send_typed_tool_lifecycle(
+ msg: ServerToolCall | ServerToolResult,
+ ) -> bool:
+ """Send tool lifecycle frames to the client."""
+ return await send_typed(msg)
+
+ ctx = None
+ session_title = ""
+ session_working_directory = "" # Will be set by client or loaded from metadata
+ session_pinned = False # Track pinned state for persistence
+ last_context_sent_directory = "" # Track when we last sent directory context
+ existing_history = None
+
+ # Use provided session_id or generate new one
+ if session_id:
+ # Validate session_id to prevent path traversal
+ try:
+ _validate_session_id(session_id)
+ except ValueError as e:
+ logger.warning("Invalid session_id rejected: %r: %s", session_id, e)
+ await websocket.close(code=1008, reason="Invalid session ID")
+ return
+
+ # Client wants to resume/continue a specific session
+ logger.debug("Client requested session: %s", session_id)
+
+ # SQLite is the source of truth — check session existence there.
+ try:
+ from code_puppy.api.db.queries import (
+ get_session_metadata,
+ session_exists,
+ )
+
+ if await session_exists(session_id):
+ existing_history = True
+ db_meta = await get_session_metadata(session_id) or {}
+ session_title = db_meta.get("title", "")
+ session_working_directory = db_meta.get("working_directory", "")
+ session_pinned = bool(db_meta.get("pinned", False))
+ except Exception as e:
+ logger.warning("Failed to check session in SQLite: %s", e)
+
+ if not existing_history:
+ logger.debug(
+ "Session %s not found in SQLite, will create new", session_id
+ )
+ else:
+ # Generate new timestamp-based session ID
+ session_timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
+ session_id = f"WS_session_{session_timestamp}"
+ logger.debug("Generated new session ID: %s", session_id)
+
+ try:
+ # --- SESSION ISOLATION: create or load via SessionManager ---
+ try:
+ if existing_history is not None:
+ # Session confirmed in SQLite — load it via SessionManager.
+ # get_or_load_session checks in-memory first, then falls
+ # back to a fresh SQLite load. SQLite is the sole source of truth.
+ ctx = await session_manager.get_or_load_session(session_id)
+ if ctx is None:
+ # SQLite confirmed this session exists but load returned None.
+ # This is unexpected (DB race, corrupt row, or schema mismatch).
+ # Log a warning and start fresh so the WebSocket can still
+ # function — silent data loss is worse than a blank session.
+ logger.warning(
+ "Session %s exists in SQLite but could not be loaded "
+ "(get_or_load_session returned None). "
+ "Starting a blank session to keep the connection alive.",
+ session_id,
+ )
+ ctx = await session_manager.create_session(session_id)
+ else:
+ # Update local state from loaded metadata
+ session_title = ctx.title
+ session_working_directory = ctx.working_directory
+ session_pinned = ctx.pinned
+ else:
+ ctx = await session_manager.create_session(session_id)
+ except Exception as e:
+ logger.warning("SessionManager init failed, falling back: %s", e)
+ try:
+ ctx = await session_manager.create_session(session_id)
+ except Exception:
+ logger.error("SessionManager fallback also failed", exc_info=True)
+ await websocket.close(code=1011, reason="Session init failed")
+ return
+
+ # Mark session as active (cancels any pending cleanup from previous disconnect)
+ await session_manager.mark_session_active(session_id)
+
+ # Initialize per-session process tracking (ContextVar isolation)
+ init_session_process_tracking()
+
+ # Set MessageBus session context for this WS connection
+ try:
+ bus = get_message_bus()
+ bus.set_session_context(session_id)
+ except Exception:
+ logger.debug("MessageBus session context not available")
+
+ # Convenience aliases — use ctx.agent everywhere instead of get_current_agent()
+ agent = ctx.agent
+ agent_name = ctx.agent_name
+ model_name = ctx.model_name
+
+ # ── Persist initial session context to SQLite (new sessions only) ───────
+ # Write a config row (agent + model) and, if a CWD is set, a directory
+ # row so the FE cold-load path sees them before the first user message.
+ # Skipped for resumed sessions — their init rows were written at creation.
+ if existing_history is None:
+ try:
+ _now_iso = datetime.datetime.now(datetime.timezone.utc).isoformat()
+ _init_agent = agent_name or "code-puppy"
+ _init_model = model_name or "unknown"
+ await write_system_message_to_sqlite(
+ session_id=session_id,
+ system_message_type="config",
+ content=f"🐶 Started with {_init_agent} ({_init_model})",
+ agent_name=_init_agent,
+ model_name=_init_model,
+ timestamp=_now_iso,
+ )
+ if session_working_directory:
+ _path_segments = session_working_directory.split("/")[-3:]
+ _relative = "/".join(s for s in _path_segments if s)
+ await write_system_message_to_sqlite(
+ session_id=session_id,
+ system_message_type="directory",
+ content=f"Starting in {_relative}",
+ system_message_path=session_working_directory,
+ agent_name=_init_agent,
+ model_name=_init_model,
+ timestamp=_now_iso,
+ )
+ except Exception as _init_exc:
+ logger.warning(
+ "Failed to write session init to SQLite: %s",
+ _init_exc,
+ exc_info=True,
+ )
+
+ # Send welcome message
+ await send_typed(
+ ServerSystem(
+ content=f"Connected! Session: {session_id}",
+ session_id=session_id,
+ agent_name=agent_name,
+ model_name=model_name,
+ resumed=existing_history is not None,
+ protocol_version=PROTOCOL_VERSION,
+ )
+ )
+
+ # If we resumed an existing session, notify the client
+ if existing_history and ctx:
+ try:
+ # History is already loaded into ctx.agent by session_manager.load_session().
+ # Pull it back out for the client-side UI metadata notification.
+ loaded_messages = ctx.agent.get_message_history()
+ message_count = len(loaded_messages) if loaded_messages else 0
+
+ # Notify client about restored session
+ await send_typed(
+ ServerSessionRestored(
+ session_id=session_id,
+ message_count=message_count,
+ title=session_title,
+ ui_metadata=[],
+ )
+ )
+
+ # Replay system messages (agent/model switches, CWD banners) to UI.
+ # These are persisted with pydantic_json=NULL so _load_from_sqlite
+ # skips them — we must send them separately so the UI shows them.
+ try:
+ from code_puppy.api.db.queries import get_active_messages
+
+ rows = await get_active_messages(session_id)
+ system_rows = [
+ r
+ for r in rows
+ if r.get("role") == "system"
+ and r.get("system_message_type")
+ in ("init", "config", "directory")
+ ]
+ for sys_row in system_rows:
+ await send_typed(
+ ServerSystem(
+ content=sys_row.get("content", ""),
+ session_id=session_id,
+ agent_name=sys_row.get("agent_name", ""),
+ model_name=sys_row.get("model_name", ""),
+ )
+ )
+ if system_rows:
+ logger.debug(
+ "Replayed %d system messages for session %s",
+ len(system_rows),
+ session_id,
+ )
+ except Exception as sys_exc:
+ logger.warning(
+ "Failed to replay system messages for session %s: %s",
+ session_id,
+ sys_exc,
+ )
+
+ agent = ctx.agent # Refresh local alias
+ logger.debug("Restored %d messages to session agent", message_count)
+ except Exception as e:
+ logger.warning("Failed to restore session history: %s", e)
+ # Track active streaming task for cancellation
+ active_drain_task = None
+ # Track active agent task (run_with_mcp) for cancellation
+ active_agent_task: asyncio.Task | None = None
+ # Track stop_draining event for cancellation across iterations
+ stop_draining = asyncio.Event()
+
+ while True:
+ try:
+ msg = await websocket.receive_json()
+
+ # Advisory validation — log but never reject
+ try:
+ _parsed = _ClientMessageAdapter.validate_python(msg)
+ except ValidationError as _val_err:
+ logger.warning(
+ "Client message failed validation: %s",
+ str(_val_err),
+ extra={
+ "type": msg.get("type")
+ if isinstance(msg, dict)
+ else "unknown"
+ },
+ )
+
+ if msg.get("type") == "switch_agent":
+ agent_name = msg.get("agent_name")
+ if agent_name:
+ try:
+ new_agent = await session_manager.switch_agent(
+ session_id, agent_name
+ )
+ agent = new_agent # Update local alias
+ ctx.agent = new_agent
+ model_name = (
+ new_agent.get_model_name()
+ if new_agent
+ else "unknown"
+ )
+
+ # Persist to SQLite so the FE cold-load path sees it
+ try:
+ await write_system_message_to_sqlite(
+ session_id=session_id,
+ system_message_type="config",
+ content=f"🔄 Switched to {agent_name} ({model_name})",
+ agent_name=agent_name,
+ model_name=model_name,
+ )
+ except Exception as _sw_exc:
+ logger.warning(
+ "Agent-switch SQLite write failed: %s",
+ _sw_exc,
+ exc_info=True,
+ )
+
+ await send_typed(
+ ServerSystem(
+ content=f"🔄 Switched to {agent_name} ({model_name})",
+ session_id=session_id,
+ agent_name=agent_name,
+ model_name=model_name,
+ )
+ )
+ except Exception as e:
+ logger.error("Error switching agent: %s", e)
+ await send_typed(
+ ServerError(
+ error=f"Failed to switch to agent {agent_name}: {str(e)}",
+ session_id=session_id,
+ )
+ )
+
+ elif msg.get("type") == "switch_model":
+ model_name = msg.get("model_name") or msg.get("model")
+ if model_name:
+ try:
+ await session_manager.switch_model(
+ session_id, model_name
+ )
+ agent = ctx.agent # Refresh local alias
+ logger.debug("Switched model to: %s", model_name)
+
+ # Persist to SQLite so the FE cold-load path sees it
+ _sw_agent = ctx.agent_name or agent_name or "code-puppy"
+ try:
+ await write_system_message_to_sqlite(
+ session_id=session_id,
+ system_message_type="config",
+ content=f"🔄 Switched to {_sw_agent} ({model_name})",
+ agent_name=_sw_agent,
+ model_name=model_name,
+ )
+ except Exception as _sw_exc:
+ logger.warning(
+ "Model-switch SQLite write failed: %s",
+ _sw_exc,
+ exc_info=True,
+ )
+
+ await send_typed(
+ ServerSystem(
+ content=f"🔄 Switched to {_sw_agent} ({model_name})",
+ session_id=session_id,
+ model_name=model_name,
+ agent_name=_sw_agent,
+ )
+ )
+ except Exception as e:
+ logger.error("Error switching model: %s", e)
+ await send_typed(
+ ServerError(
+ error=f"Failed to switch to model {model_name}: {str(e)}",
+ session_id=session_id,
+ )
+ )
+ else:
+ await send_typed(
+ ServerError(
+ error="No model_name provided for switch_model",
+ session_id=session_id,
+ )
+ )
+
+ elif msg.get("type") == "switch_session":
+ """
+ Switch to a different session without reconnecting WebSocket.
+
+ How it works:
+ 1. Client sends: {"type": "switch_session", "session_id": "WS_session_20260120_124356"}
+ 2. Server loads session from ~/.code_puppy/ws_sessions/{session_id}.pkl
+ 3. Server restores message history to the current agent
+ 4. Server responds with session_switched event containing message count and title
+ 5. Client can continue chatting with full conversation context
+
+ This approach keeps a single WebSocket connection alive while allowing
+ users to switch between multiple sessions instantly. All session data
+ stays on the local filesystem for privacy.
+ """
+ # Cancel any active streaming first
+ if active_drain_task and not active_drain_task.done():
+ logger.debug(
+ "Cancelling active streaming due to session switch"
+ )
+ stop_draining.set() # Signal drain to stop
+ active_drain_task.cancel()
+ try:
+ await active_drain_task
+ except asyncio.CancelledError:
+ pass
+ stop_draining.clear() # Reset for next streaming
+
+ new_session_id = msg.get("session_id")
+ if not new_session_id:
+ await send_typed(
+ ServerError(
+ error="No session_id provided for switch_session",
+ session_id=session_id,
+ )
+ )
+ continue
+
+ # Validate session_id to prevent path traversal
+ try:
+ _validate_session_id(new_session_id)
+ except ValueError as e:
+ logger.warning(
+ f"Invalid session_id in switch_session: {new_session_id!r}"
+ )
+ await send_typed(
+ ServerError(
+ error=f"Invalid session ID: {e}",
+ session_id=session_id,
+ )
+ )
+ continue
+
+ logger.debug("Switching to session: %s", new_session_id)
+
+ try:
+ # Check if target session exists in SQLite
+ try:
+ from code_puppy.api.db.queries import (
+ session_exists as _se,
+ )
+
+ _target_exists = await _se(new_session_id)
+ except Exception:
+ _target_exists = False
+
+ if not _target_exists:
+ # Session doesn't exist - create new one with this ID
+ logger.debug(
+ f"Session {new_session_id} not found, creating new"
+ )
+
+ # Save current session and mark inactive (keep in memory for 15 min)
+ try:
+ await session_manager.save_session(session_id)
+ except Exception:
+ pass
+ await session_manager.mark_session_inactive(session_id)
+
+ session_id = new_session_id
+ session_title = ""
+ session_working_directory = ""
+ session_pinned = False
+
+ ctx = await session_manager.create_session(session_id)
+ # Mark the new session as active
+ await session_manager.mark_session_active(session_id)
+ agent = ctx.agent
+ agent_name = ctx.agent_name
+ model_name = ctx.model_name
+
+ await send_typed(
+ ServerSessionSwitched(
+ session_id=new_session_id,
+ message_count=0,
+ title="",
+ created=True,
+ agent_name=agent_name,
+ model_name=model_name,
+ )
+ )
+ continue
+
+ # Load existing session via SessionManager
+ # Save current session and mark inactive (keep in memory for 15 min)
+ try:
+ await session_manager.save_session(session_id)
+ except Exception:
+ pass
+ await session_manager.mark_session_inactive(session_id)
+
+ session_id = new_session_id
+
+ # Try loading via SessionManager (checks in-memory first, then SQLite)
+ loaded_ctx = await session_manager.get_or_load_session(
+ session_id
+ )
+ if loaded_ctx is not None:
+ ctx = loaded_ctx
+ session_title = ctx.title
+ session_working_directory = ctx.working_directory
+ session_pinned = ctx.pinned
+ else:
+ # Legacy session or HMAC missing — load metadata
+ # from JSON (safe) and create fresh context
+ new_title = ""
+ new_working_directory = ""
+ new_pinned = False
+ try:
+ from code_puppy.api.db.queries import (
+ get_session_metadata as _gsm,
+ )
+
+ _sm = await _gsm(session_id) or {}
+ new_title = _sm.get("title", "")
+ new_working_directory = _sm.get(
+ "working_directory", ""
+ )
+ new_pinned = bool(_sm.get("pinned", False))
+ except Exception:
+ pass
+
+ ctx = await session_manager.create_session(session_id)
+ ctx.title = new_title
+ ctx.working_directory = new_working_directory
+ ctx.pinned = new_pinned
+ session_title = new_title
+ session_working_directory = new_working_directory
+ session_pinned = new_pinned
+
+ # Mark the new session as active
+ await session_manager.mark_session_active(session_id)
+
+ agent = ctx.agent
+ agent_name = ctx.agent_name
+ model_name = ctx.model_name
+ message_count = len(ctx.agent.get_message_history() or [])
+ logger.debug(
+ f"Restored {message_count} messages to session agent"
+ )
+
+ await send_typed(
+ ServerSessionSwitched(
+ session_id=new_session_id,
+ message_count=message_count,
+ title=session_title,
+ working_directory=session_working_directory,
+ created=False,
+ agent_name=agent_name,
+ model_name=model_name,
+ )
+ )
+
+ logger.debug(
+ f"Switched to session {new_session_id} with {message_count} messages"
+ )
+
+ except Exception as e:
+ logger.error("Error switching session: %s", e)
+ await send_typed(
+ ServerError(
+ error=f"Failed to switch to session {new_session_id}: {str(e)}",
+ session_id=session_id,
+ )
+ )
+
+ elif msg.get("type") == "set_working_directory":
+ new_directory = msg.get("directory", "")
+ if new_directory:
+ # Expand ~ and resolve symlinks before validation
+ new_directory = str(
+ Path(new_directory).expanduser().resolve()
+ )
+ logger.info(
+ "[CWD DEBUG] set_working_directory received: new=%r, current=%r, session=%s",
+ new_directory,
+ session_working_directory,
+ session_id,
+ )
+ # Validate the directory exists
+ if Path(new_directory).is_dir():
+ # Skip if directory hasn't actually changed (avoids duplicate banners on reload)
+ if new_directory == session_working_directory:
+ logger.info(
+ "[CWD DEBUG] Skipping unchanged directory: %r, session=%s",
+ new_directory,
+ session_id,
+ )
+ await send_typed(
+ ServerWorkingDirectoryChanged(
+ directory=new_directory,
+ success=True,
+ session_id=session_id,
+ unchanged=True,
+ )
+ )
+ continue # Skip to next message, don't write banner
+
+ session_working_directory = new_directory
+ logger.debug(
+ "Working directory set to: %s",
+ session_working_directory,
+ )
+
+ # NOTE: Do NOT append raw dict entries into agent
+ # message history here. The runtime expects typed
+ # ModelMessage objects; dict injection can corrupt
+ # subsequent turns and lead to result=None failures.
+
+ # Persist directory banner to SQLite so FE cold-load sees it
+ try:
+ # Bug 4: use ctx.agent_name / ctx.model_name directly
+ # so this works even when ctx.agent is None.
+ _cwd_agent = (
+ ctx.agent_name if ctx else None
+ ) or "code-puppy"
+ _cwd_model = (
+ ctx.model_name if ctx else None
+ ) or "unknown"
+ _cwd_segs = session_working_directory.split("/")[
+ -3:
+ ]
+ _cwd_rel = "/".join(s for s in _cwd_segs if s)
+ from code_puppy.config import get_puppy_name as _gpn
+
+ _pname = _gpn() or "puppy"
+ logger.info(
+ "[CWD DEBUG] Writing CWD banner to SQLite: path=%r, session=%s",
+ session_working_directory,
+ session_id,
+ )
+ await write_system_message_to_sqlite(
+ session_id=session_id,
+ system_message_type="directory",
+ content=f"{_pname} is now at {_cwd_rel}",
+ system_message_path=session_working_directory,
+ agent_name=_cwd_agent,
+ model_name=_cwd_model,
+ )
+ # Bug 5: also stamp working_directory on the sessions row
+ _now_cwd = datetime.datetime.now(
+ datetime.timezone.utc
+ ).isoformat()
+ await update_session_working_directory(
+ session_id=session_id,
+ working_directory=session_working_directory,
+ updated_at=_now_cwd,
+ )
+ except Exception as _cwd_exc:
+ logger.warning(
+ "CWD SQLite write failed: %s",
+ _cwd_exc,
+ exc_info=True,
+ )
+
+ await send_typed(
+ ServerWorkingDirectoryChanged(
+ directory=session_working_directory,
+ success=True,
+ session_id=session_id,
+ )
+ )
+ else:
+ await send_typed(
+ ServerWorkingDirectoryChanged(
+ directory=new_directory,
+ success=False,
+ error="Directory does not exist",
+ session_id=session_id,
+ )
+ )
+ else:
+ await send_typed(
+ ServerError(
+ error="No directory provided for set_working_directory",
+ session_id=session_id,
+ )
+ )
+
+ elif msg.get("type") == "update_session_meta":
+ # Update session metadata (pinned, title) — persisted to SQLite.
+ try:
+ # Update in-memory state (local vars AND SessionContext)
+ if "pinned" in msg and isinstance(msg["pinned"], bool):
+ session_pinned = msg["pinned"]
+ if ctx is not None:
+ ctx.pinned = session_pinned
+ if "title" in msg and isinstance(msg["title"], str):
+ session_title = msg["title"]
+ if ctx is not None:
+ ctx.title = session_title
+
+ # Persist to SQLite (single source of truth).
+ # Use a targeted UPDATE — NOT upsert_session() — so that
+ # message_count, total_tokens, and other stats are
+ # never zeroed out by a rename/pin operation.
+ try:
+ from datetime import datetime as _dt
+ from datetime import timezone as _tz
+
+ from code_puppy.api.db.queries import (
+ update_session_meta_fields,
+ )
+
+ await update_session_meta_fields(
+ session_id=session_id,
+ title=session_title,
+ pinned=session_pinned,
+ updated_at=_dt.now(_tz.utc).isoformat(),
+ )
+ logger.debug(
+ f"Updated session meta in SQLite for {session_id}: pinned={session_pinned}"
+ )
+ except Exception as meta_exc:
+ logger.warning(
+ "Failed to persist session meta to SQLite: %s",
+ meta_exc,
+ )
+
+ await send_typed(
+ ServerSessionMetaUpdated(
+ session_id=session_id,
+ pinned=session_pinned,
+ title=session_title,
+ )
+ )
+ except Exception as e:
+ logger.error("Error updating session meta: %s", e)
+ await send_typed(
+ ServerError(
+ error=f"Failed to update session metadata: {str(e)}",
+ session_id=session_id,
+ )
+ )
+
+ elif msg.get("type") == "get_config":
+ config_key = msg.get("key", "")
+ if config_key:
+ from code_puppy.config import get_value
+
+ value = get_value(config_key)
+ await send_typed(
+ ServerConfigValue(
+ key=config_key,
+ value=value,
+ session_id=session_id,
+ )
+ )
+ else:
+ await send_typed(
+ ServerError(
+ error="No key provided for get_config",
+ session_id=session_id,
+ )
+ )
+
+ elif msg.get("type") == "set_config":
+ config_key = msg.get("key", "")
+ config_value = msg.get("value", "")
+ if config_key:
+ from code_puppy.config import set_config_value
+
+ try:
+ set_config_value(config_key, str(config_value))
+ logger.debug(
+ f"Config set: {config_key} = {config_value}"
+ )
+ await send_typed(
+ ServerConfigValue(
+ key=config_key,
+ value=config_value,
+ success=True,
+ session_id=session_id,
+ )
+ )
+ except Exception as e:
+ await send_typed(
+ ServerError(
+ error=f"Failed to set config: {e}",
+ session_id=session_id,
+ )
+ )
+ else:
+ await send_typed(
+ ServerError(
+ error="No key provided for set_config",
+ session_id=session_id,
+ )
+ )
+
+ # Handle slash command execution
+ elif msg.get("type") == "command":
+ command_str = msg.get("command", "")
+ logger.debug("Command requested: %s", command_str)
+
+ try:
+ from io import StringIO
+
+ from rich.console import Console
+
+ from code_puppy.command_line.command_handler import (
+ get_commands_help,
+ handle_command,
+ )
+
+ output = None
+ captured_messages = []
+
+ # Special handling for /help - we can get the text directly
+ cmd_name = (
+ command_str.strip().lstrip("/").split()[0]
+ if command_str.strip()
+ else ""
+ )
+ if cmd_name in ("help", "h"):
+ # Get help text directly
+ help_text = get_commands_help()
+ # Render to plain text
+ output_buffer = StringIO()
+ console = Console(
+ file=output_buffer,
+ force_terminal=False,
+ width=100,
+ no_color=True,
+ )
+ console.print(help_text)
+ output = output_buffer.getvalue().strip()
+ result = True
+ else:
+ # For other commands, just execute and report success/failure
+ # The actual output goes to the message queue/terminal
+ result = handle_command(command_str)
+ # Some commands might return a string as output
+ if isinstance(result, str):
+ output = result
+ result = True
+
+ # Send command result
+ await send_typed(
+ ServerCommandResult(
+ command=command_str,
+ success=result is True or result is not False,
+ output=output,
+ messages=captured_messages,
+ result=str(result)
+ if result and result is not True
+ else None,
+ session_id=session_id,
+ )
+ )
+ logger.debug(
+ f"Command executed: {command_str} -> success={result is True or result is not False}, output_len={len(output) if output else 0}"
+ )
+
+ except Exception as cmd_error:
+ import traceback
+
+ error_details = traceback.format_exc()
+ logger.error("Command error: %s", cmd_error)
+ logger.error("Traceback: %s", error_details)
+ await send_typed(
+ ServerCommandResult(
+ command=command_str,
+ success=False,
+ error=str(cmd_error),
+ session_id=session_id,
+ )
+ )
+ continue
+
+ # Handle cancel/interrupt request
+ elif msg.get("type") == "cancel":
+ logger.debug(
+ "Cancel request received - stopping active streaming and agent task"
+ )
+
+ # Cancel any active streaming
+ if active_drain_task and not active_drain_task.done():
+ stop_draining.set() # Signal drain to stop
+ active_drain_task.cancel()
+ try:
+ await active_drain_task
+ except asyncio.CancelledError:
+ pass
+ stop_draining.clear() # Reset for next streaming
+ logger.debug("Active streaming cancelled")
+
+ # Cancel the in-flight agent run (run_with_mcp) if present
+ if active_agent_task and not active_agent_task.done():
+ logger.debug(
+ "Cancelling active agent task due to user interrupt"
+ )
+ active_agent_task.cancel()
+ try:
+ await active_agent_task
+ except asyncio.CancelledError:
+ logger.debug("Active agent task cancelled successfully")
+ active_agent_task = None
+
+ # Send confirmation
+ await send_typed(
+ ServerStatus(
+ status="cancelled",
+ session_id=session_id,
+ )
+ )
+ continue
+
+ elif msg.get("type") == "permission_response":
+ # Handle permission response from user
+ from code_puppy.api.permissions import (
+ handle_permission_response,
+ )
+
+ request_id = msg.get("request_id")
+ approved = msg.get("approved", False)
+
+ if request_id:
+ handled = handle_permission_response(request_id, approved)
+ if handled:
+ logger.debug(
+ f"[Permission] ✅ Handled response: {request_id} = {approved}"
+ )
+ else:
+ logger.warning(
+ f"[Permission] ❌ Unknown request: {request_id}"
+ )
+ else:
+ logger.error(
+ "[WebSocket] ❌ No request_id in permission_response!"
+ )
+ continue
+
+ elif msg.get("type") == "message":
+ # Set WebSocket context for permission requests
+ from code_puppy.api.permission_plugin import (
+ set_suppress_emitter_tool_events,
+ set_websocket_context,
+ )
+
+ set_websocket_context(websocket, session_id)
+
+ user_message = msg.get("content", "")
+
+ # Initialize attachment tracking variables at message scope
+ original_user_message = user_message # Store clean message
+ attachment_metadata = [] # Will be populated if attachments exist
+
+ # Check if a specific model was requested for this message
+ requested_model = msg.get("model")
+ if requested_model:
+ current_model = ctx.model_name
+ if requested_model != current_model:
+ try:
+ await session_manager.switch_model(
+ session_id, requested_model
+ )
+ agent = ctx.agent # Refresh alias
+ model_name = requested_model
+ logger.debug(
+ f"Switching to model {requested_model} for this message"
+ )
+ except Exception as e:
+ logger.warning("Failed to switch model: %s", e)
+
+ # Apply model_settings from frontend (reasoning_effort, verbosity, etc.)
+ model_settings = msg.get("model_settings", {})
+ if model_settings:
+ from code_puppy.config import set_model_setting
+
+ target_model = requested_model or get_global_model_name()
+ for setting_name, value in model_settings.items():
+ try:
+ set_model_setting(target_model, setting_name, value)
+ logger.debug(
+ f"Applied model_setting {setting_name}={value} for {target_model}"
+ )
+ except Exception as e:
+ logger.warning(
+ f"Failed to apply model_setting {setting_name}: {e}"
+ )
+
+ if not user_message.strip():
+ await send_typed(
+ ServerError(
+ error="Empty message",
+ session_id=session_id,
+ )
+ )
+ continue
+
+ logger.debug(
+ f"Chat message from client: {user_message[:50]}..."
+ )
+
+ # Echo the user message back
+ await send_typed(
+ ServerUserMessage(
+ content=user_message,
+ session_id=session_id,
+ )
+ )
+
+ # Get the session agent and process the message
+ try:
+ agent = ctx.agent
+
+ # Reload agent if a different model was requested
+ if requested_model:
+ agent.reload_code_generation_agent()
+ logger.debug(
+ f"Reloaded agent with model: {requested_model}"
+ )
+
+ if not agent:
+ await send_typed(
+ ServerError(
+ error="Agent not available. Please start Code Puppy first.",
+ session_id=session_id,
+ )
+ )
+ continue
+
+ # Subscribe to frontend emitter and run drain task CONCURRENTLY
+ event_queue = None
+ collected_text = []
+ stop_draining.clear() # Reset for this message
+ drain_task = None
+ active_parts: dict[
+ int, dict
+ ] = {} # Track message parts by index
+ tool_id_aliases: dict[str, str] = {}
+ tool_group_ids: dict[
+ str, str
+ ] = {} # tool_id -> tool_group_id mapping
+ b1_streaming_used = (
+ False # Track if B1 streaming sent any content
+ )
+ agent_error = None # Will hold exception if agent run fails
+
+ async def drain_events_concurrent(
+ ready_event: asyncio.Event = None,
+ ):
+ """Background task to drain events and send structured messages in real-time."""
+ nonlocal collected_text, b1_streaming_used, ws_closed
+ import time as time_module
+
+ # Capture agent and model metadata at the start.
+ # Chain `or` fallbacks so that an empty-string agent.name
+ # (possible before agent is fully initialised) still resolves
+ # to a meaningful value via the session context defaults.
+ current_agent_name = (
+ (agent.name if agent else "")
+ or ctx.agent_name
+ or "code-puppy"
+ )
+ current_model_name = (
+ (agent.get_model_name() if agent else "")
+ or ctx.model_name
+ or "unknown"
+ )
+ current_tool_name = None # Track active tool name
+ current_tool_group_id: str | None = (
+ None # Track current tool batch group ID
+ )
+
+ # Track pending tool calls for tool_result generation
+ # {tool_id: {tool_name, start_time, part_index}}
+ pending_tool_calls: dict[str, dict] = {}
+
+ def resolve_pending_tool_id(
+ *,
+ tool_call_id: str | None = None,
+ tool_name: str | None = None,
+ ) -> str | None:
+ """Resolve canonical frontend tool_id for a backend tool call reference."""
+ if (
+ tool_call_id
+ and tool_call_id in pending_tool_calls
+ ):
+ return tool_call_id
+
+ if tool_call_id:
+ for (
+ _pending_id,
+ _pending_info,
+ ) in pending_tool_calls.items():
+ if (
+ _pending_info.get("raw_tool_call_id")
+ == tool_call_id
+ ):
+ return _pending_id
+
+ if tool_name:
+ for (
+ _pending_id,
+ _pending_info,
+ ) in pending_tool_calls.items():
+ if (
+ _pending_info.get("tool_name")
+ == tool_name
+ ):
+ return _pending_id
+
+ return None
+
+ def resolve_tool_group_id(
+ *,
+ tool_id: str | None = None,
+ pending_info: dict | None = None,
+ fallback_group_id: str | None = None,
+ tool_name: str | None = None,
+ source: str = "unknown",
+ ) -> str:
+ """Return a non-empty tool_group_id for tool lifecycle frames.
+
+ Priority order:
+ 1) pending tool info
+ 2) tool_id -> group map
+ 3) explicit fallback passed by caller
+ 4) current in-flight batch group
+ 5) synthetic deterministic-ish fallback (last resort)
+ """
+ nonlocal current_tool_group_id
+
+ group_id = None
+ if pending_info is not None:
+ group_id = pending_info.get("tool_group_id")
+
+ if not group_id and tool_id:
+ group_id = tool_group_ids.get(tool_id)
+
+ if not group_id and fallback_group_id:
+ group_id = fallback_group_id
+
+ if not group_id:
+ group_id = current_tool_group_id
+
+ if not group_id:
+ raw_hint = tool_id or tool_name or "unknown"
+ stable_hint = (
+ re.sub(
+ r"[^a-z0-9_-]",
+ "-",
+ raw_hint.lower(),
+ )[:24].strip("-")
+ or "unknown"
+ )
+ group_id = f"tg-fallback-{stable_hint}-{str(uuid.uuid4())[:6]}"
+ logger.warning(
+ "[ws] Synthesized missing tool_group_id "
+ "for source=%s tool_id=%s tool_name=%s",
+ source,
+ tool_id,
+ tool_name,
+ )
+
+ if tool_id:
+ tool_group_ids[tool_id] = group_id
+ if pending_info is not None:
+ pending_info["tool_group_id"] = group_id
+ elif tool_id in pending_tool_calls:
+ pending_tool_calls[tool_id][
+ "tool_group_id"
+ ] = group_id
+
+ if current_tool_group_id is None:
+ current_tool_group_id = group_id
+
+ return group_id
+
+ event_count = 0
+ first_iteration = True
+ while not stop_draining.is_set():
+ # Exit if WebSocket is closed
+ if ws_closed:
+ logger.debug(
+ "WebSocket closed, exiting drain loop"
+ )
+ break
+ # Signal ready on first iteration (we're in the loop now)
+ if first_iteration and ready_event:
+ ready_event.set()
+ first_iteration = False
+ # Yield to allow run_with_mcp to start and emit first events
+ await asyncio.sleep(0)
+ continue # Re-enter loop to collect any queued events
+ # Event-driven batch collection with 10ms timeout for responsiveness.
+ # Instead of polling the clock, we use asyncio.wait_for to block until
+ # the first event arrives, then collect any additional events already queued.
+ events_to_send = []
+ try:
+ # Wait for first event with 10ms timeout (blocks efficiently)
+ first_event = await asyncio.wait_for(
+ event_queue.get(), timeout=0.01
+ )
+ events_to_send.append(first_event)
+ event_count += 1
+
+ # Collect any additional events already in queue (non-blocking)
+ # This keeps the batching benefit without polling the clock
+ while not event_queue.empty():
+ try:
+ event = event_queue.get_nowait()
+ events_to_send.append(event)
+ event_count += 1
+ except Exception:
+ break
+
+ except asyncio.TimeoutError:
+ # No events available within 10ms timeout
+ # No polling needed - asyncio.wait_for blocks efficiently
+ pass
+
+ # Log batch composition for debugging
+ if events_to_send:
+ event_types = {}
+ for e in events_to_send:
+ et = e.get("type", "unknown")
+ event_types[et] = event_types.get(et, 0) + 1
+ logger.debug(
+ "[%s] Batch: %d events - %s",
+ session_id,
+ len(events_to_send),
+ event_types,
+ )
+
+ # Process collected events
+ for event in events_to_send:
+ # If cancellation was requested, stop processing immediately
+ if stop_draining.is_set():
+ logger.debug(
+ "stop_draining set during batch - stopping event processing"
+ )
+ break
+
+ event_type = event.get("type", "")
+ event_data = event.get("data", {})
+
+ try:
+ # Handle tool call events
+ if event_type == "tool_call_start":
+ tool_name = event_data.get(
+ "tool_name", "unknown"
+ )
+ tool_args = event_data.get(
+ "tool_args", {}
+ )
+ tool_id = str(uuid.uuid4())[:8]
+
+ # Generate tool group ID for this batch if not already set
+ if current_tool_group_id is None:
+ current_tool_group_id = (
+ f"tg-{str(uuid.uuid4())[:8]}"
+ )
+
+ logger.debug(
+ "[ws] tool_call: %s", tool_name
+ )
+
+ # Track current tool name
+ current_tool_name = tool_name
+
+ # Register in pending_tool_calls so
+ # tool_call_complete can echo back the
+ # same tool_id the frontend already knows.
+ pending_tool_calls[tool_id] = {
+ "tool_name": tool_name,
+ "start_time": time_module.time(),
+ "tool_group_id": current_tool_group_id,
+ }
+
+ await send_typed_tool_lifecycle(
+ ServerToolCall(
+ tool_id=tool_id,
+ tool_name=tool_name,
+ args=tool_args,
+ timestamp=time_module.time(),
+ session_id=session_id,
+ agent_name=current_agent_name,
+ model_name=current_model_name,
+ tool_group_id=current_tool_group_id,
+ )
+ )
+
+ elif event_type == "tool_call_complete":
+ tool_name = event_data.get(
+ "tool_name", "unknown"
+ )
+ result = event_data.get(
+ "result",
+ event_data.get(
+ "result_summary", ""
+ ),
+ )
+ success = event_data.get(
+ "success", True
+ )
+ duration = event_data.get(
+ "duration_ms", 0
+ )
+
+ logger.debug(
+ "[ws] tool_result: %s", tool_name
+ )
+
+ # Clear current tool name after completion
+ current_tool_name = None
+
+ # Match the tool_id we generated at
+ # tool_call_start so the frontend can
+ # correlate result → call by ID, not
+ # just by name (names are not unique).
+ matching_tool_id = next(
+ (
+ tid
+ for tid, info in pending_tool_calls.items()
+ if info["tool_name"]
+ == tool_name
+ ),
+ None,
+ )
+ matching_pending_info = (
+ pending_tool_calls.get(
+ matching_tool_id
+ )
+ if matching_tool_id
+ else None
+ )
+ tool_group_id_for_result = resolve_tool_group_id(
+ tool_id=matching_tool_id,
+ pending_info=matching_pending_info,
+ fallback_group_id=current_tool_group_id,
+ tool_name=tool_name,
+ source="tool_call_complete",
+ )
+
+ if matching_tool_id:
+ pending_tool_calls.pop(
+ matching_tool_id, None
+ )
+
+ await send_typed_tool_lifecycle(
+ ServerToolResult(
+ tool_id=matching_tool_id,
+ tool_name=tool_name,
+ result=result,
+ success=success,
+ duration_ms=duration,
+ timestamp=time_module.time(),
+ session_id=session_id,
+ agent_name=current_agent_name,
+ model_name=current_model_name,
+ tool_group_id=tool_group_id_for_result,
+ )
+ )
+
+ elif event_type == "agent_invoked":
+ agent_name_inv = event_data.get(
+ "agent_name", "unknown"
+ )
+ prompt_preview = event_data.get(
+ "prompt_preview", ""
+ )
+
+ logger.debug(
+ "[ws] agent_invoked: %s",
+ agent_name_inv,
+ )
+
+ await send_typed(
+ ServerAgentInvoked(
+ agent_name=agent_name_inv,
+ prompt_preview=prompt_preview,
+ timestamp=time_module.time(),
+ session_id=session_id,
+ )
+ )
+
+ # Handle streaming events
+ elif event_type == "stream_event":
+ inner_type = event_data.get(
+ "event_type", ""
+ )
+ inner_data = event_data.get(
+ "event_data", {}
+ )
+ if inner_type == "part_start":
+ part_index = inner_data.get(
+ "index", 0
+ )
+ part_type = inner_data.get(
+ "part_type", "unknown"
+ )
+ logger.warning(
+ "[ws] part_start: part_type=%s, part_index=%s",
+ part_type,
+ part_index,
+ )
+
+ # Extract initial content from the part if present
+ # The part object may have content already (especially for TextPart/ThinkingPart)
+ part_obj = inner_data.get(
+ "part", {}
+ )
+ initial_content = ""
+
+ if (
+ hasattr(part_obj, "content")
+ and part_obj.content
+ ):
+ initial_content = (
+ part_obj.content
+ )
+ elif isinstance(
+ part_obj, dict
+ ) and part_obj.get("content"):
+ initial_content = part_obj.get(
+ "content", ""
+ )
+
+ if part_type in (
+ "TextPart",
+ "ThinkingPart",
+ ):
+ msg_type = (
+ "thinking"
+ if part_type
+ == "ThinkingPart"
+ else "text"
+ )
+
+ # When a TextPart starts, any pending tool calls have completed
+ # Send status-only tool_result during streaming to update UI
+ # The actual result data will be sent later from extraction code
+ if (
+ pending_tool_calls
+ and part_type == "TextPart"
+ ):
+ for (
+ tool_id,
+ tool_info,
+ ) in list(
+ pending_tool_calls.items()
+ ):
+ if tool_info.get(
+ "status_only_sent"
+ ):
+ continue
+ duration_ms = (
+ time_module.time()
+ - tool_info[
+ "start_time"
+ ]
+ ) * 1000
+ logger.debug(
+ f"[WebSocket] Sending status-only tool_result for: {tool_info['tool_name']} (duration: {duration_ms:.1f}ms)"
+ )
+ await send_typed(
+ ServerToolResult(
+ tool_id=tool_id,
+ result={
+ "_status": "completed",
+ "_pending_full_result": True,
+ },
+ tool_name=tool_info[
+ "tool_name"
+ ],
+ success=True,
+ duration_ms=duration_ms,
+ timestamp=time_module.time(),
+ session_id=session_id,
+ agent_name=current_agent_name,
+ model_name=current_model_name,
+ tool_group_id=resolve_tool_group_id(
+ tool_id=tool_id,
+ pending_info=tool_info,
+ fallback_group_id=current_tool_group_id,
+ tool_name=tool_info.get(
+ "tool_name"
+ ),
+ source="status_only_result",
+ ),
+ )
+ )
+ tool_info[
+ "status_only_sent"
+ ] = True
+ current_tool_name = None
+
+ # Check if part already exists (created by early delta)
+ if part_index in active_parts:
+ # Just update the type, keep existing message_id
+ active_parts[part_index][
+ "type"
+ ] = msg_type
+ message_id = active_parts[
+ part_index
+ ]["id"]
+ # If there's initial content, add it to the existing accumulated content
+ if initial_content:
+ active_parts[
+ part_index
+ ]["content"] = (
+ initial_content
+ + active_parts[
+ part_index
+ ]["content"]
+ )
+ collected_text.insert(
+ 0, initial_content
+ ) # Prepend to collected text too
+ logger.debug(
+ f"[Stream Debug] Part already exists, reusing message_id={message_id}"
+ )
+ # Don't send another start event
+ continue
+
+ message_id = f"msg-{int(time_module.time() * 1000)}-{part_index}"
+ active_parts[part_index] = {
+ "id": message_id,
+ "type": msg_type,
+ "content": initial_content, # Start with initial content if present
+ }
+
+ # If there's initial content, add it to collected_text
+ if initial_content:
+ collected_text.append(
+ initial_content
+ )
+
+ # New assistant output part indicates turn boundary for tool grouping
+ if part_index == 0:
+ current_tool_group_id = None
+
+ await safe_send_json(
+ ServerAssistantMessageStart(
+ message_id=message_id,
+ part_type=msg_type,
+ part_index=part_index,
+ timestamp=time_module.time(),
+ session_id=session_id,
+ agent_name=current_agent_name,
+ model_name=current_model_name,
+ tool_name=current_tool_name,
+ ).model_dump(
+ exclude_none=True
+ )
+ )
+
+ # If there's initial content, send it as a delta immediately
+ if initial_content:
+ _delta = ServerAssistantMessageDelta.model_construct(
+ type="assistant_message_delta",
+ message_id=message_id,
+ content=initial_content,
+ part_index=part_index,
+ session_id=session_id,
+ agent_name=current_agent_name,
+ model_name=current_model_name,
+ tool_name=current_tool_name,
+ )
+ await safe_send_json(
+ _delta.model_dump(
+ exclude_none=True
+ )
+ )
+ # Mark that B1 streaming was used (nonlocal already declared above)
+ b1_streaming_used = True
+
+ elif part_type == "ToolCallPart":
+ # Extract tool info from the part
+ tool_name = "unknown"
+ tool_call_id = None
+ tool_args_str = ""
+
+ # Extract tool info from part_obj (dict or object)
+ if hasattr(
+ part_obj, "tool_name"
+ ):
+ tool_name = (
+ part_obj.tool_name
+ )
+ elif isinstance(part_obj, dict):
+ tool_name = part_obj.get(
+ "tool_name", "unknown"
+ )
+
+ if hasattr(
+ part_obj, "tool_call_id"
+ ):
+ tool_call_id = (
+ part_obj.tool_call_id
+ )
+ elif isinstance(part_obj, dict):
+ tool_call_id = part_obj.get(
+ "tool_call_id"
+ )
+
+ if hasattr(part_obj, "args"):
+ tool_args_str = (
+ part_obj.args or ""
+ )
+ elif isinstance(part_obj, dict):
+ tool_args_str = (
+ part_obj.get("args", "")
+ or ""
+ )
+
+ tool_id = (
+ tool_call_id
+ or str(uuid.uuid4())[:8]
+ )
+
+ # Track this tool call part with args buffer for accumulation
+ # Don't send tool_call yet - wait for part_end when args are complete
+ active_parts[part_index] = {
+ "id": tool_id,
+ "raw_tool_call_id": tool_call_id,
+ "type": "tool_call",
+ "tool_name": tool_name,
+ "args": tool_args_str,
+ "args_buffer": tool_args_str, # Buffer for accumulating args_delta
+ "start_time": time_module.time(), # Track when tool started
+ }
+
+ # Track current tool name
+ current_tool_name = tool_name
+
+ logger.debug(
+ f"[WebSocket] ToolCallPart started: {tool_name} (id: {tool_id})"
+ )
+ # tool_call event will be sent on part_end when args are complete
+
+ elif part_type == "ToolReturnPart":
+ logger.info(
+ f"[WebSocket] ToolReturnPart detected! part_index={part_index}"
+ )
+ # Extract tool result info from the part
+ tool_call_id = None
+ tool_content = None
+
+ # Extract tool_call_id
+ if hasattr(
+ part_obj, "tool_call_id"
+ ):
+ tool_call_id = (
+ part_obj.tool_call_id
+ )
+ elif isinstance(part_obj, dict):
+ tool_call_id = part_obj.get(
+ "tool_call_id"
+ )
+
+ # Extract content (the tool result)
+ if hasattr(part_obj, "content"):
+ tool_content = (
+ part_obj.content
+ )
+ elif isinstance(part_obj, dict):
+ tool_content = part_obj.get(
+ "content"
+ )
+
+ # Try to serialize complex result objects
+ if (
+ tool_content
+ and not isinstance(
+ tool_content,
+ (
+ str,
+ dict,
+ list,
+ int,
+ float,
+ bool,
+ type(None),
+ ),
+ )
+ ):
+ try:
+ import json
+
+ # Try to convert to dict first (for Pydantic models)
+ if hasattr(
+ tool_content,
+ "model_dump",
+ ):
+ tool_content = tool_content.model_dump()
+ elif hasattr(
+ tool_content, "dict"
+ ):
+ tool_content = tool_content.dict()
+ elif hasattr(
+ tool_content,
+ "__dict__",
+ ):
+ tool_content = tool_content.__dict__
+ else:
+ tool_content = str(
+ tool_content
+ )
+ except Exception as e:
+ logger.debug(
+ f"[WebSocket] Could not serialize tool result: {e}"
+ )
+ tool_content = str(
+ tool_content
+ )
+
+ # Find the corresponding pending tool call, store and SEND the result
+ _result_sent = False
+ if tool_call_id:
+ resolved_pending_id = resolve_pending_tool_id(
+ tool_call_id=tool_call_id
+ )
+ if resolved_pending_id:
+ _result_sent = True
+ pending_info = pending_tool_calls[
+ resolved_pending_id
+ ]
+ pending_info[
+ "result"
+ ] = tool_content
+ _tool_name = (
+ pending_info.get(
+ "tool_name",
+ "unknown",
+ )
+ )
+ _start_time = pending_info.get(
+ "start_time",
+ time_module.time(),
+ )
+ _duration_ms = (
+ time_module.time()
+ - _start_time
+ ) * 1000
+ _group_id = resolve_tool_group_id(
+ tool_id=resolved_pending_id,
+ pending_info=pending_info,
+ fallback_group_id=current_tool_group_id,
+ tool_name=_tool_name,
+ source="tool_return_resolved",
+ )
+ logger.info(
+ f"[WebSocket] ToolReturnPart: Sending result for {_tool_name} (id: {resolved_pending_id}, raw: {tool_call_id})"
+ )
+ # Send the REAL tool_result with actual content
+ await send_typed_tool_lifecycle(
+ ServerToolResult(
+ tool_id=resolved_pending_id,
+ tool_name=_tool_name,
+ result=tool_content,
+ success=True,
+ duration_ms=_duration_ms,
+ timestamp=time_module.time(),
+ session_id=session_id,
+ agent_name=current_agent_name
+ or "code-puppy",
+ model_name=current_model_name
+ or "unknown",
+ tool_group_id=_group_id,
+ )
+ )
+ else:
+ logger.warning(
+ f"[WebSocket] ToolReturnPart: Could not resolve tool_call_id {tool_call_id}, pending keys: {list(pending_tool_calls.keys())}"
+ )
+
+ # Fallback: proximity-based matching if no tool_call_id or resolution failed
+ if not _result_sent:
+ for (
+ pending_id,
+ pending_info,
+ ) in sorted(
+ pending_tool_calls.items(),
+ key=lambda x: abs(
+ x[1].get(
+ "part_index",
+ 9999,
+ )
+ - part_index
+ ),
+ ):
+ if (
+ abs(
+ pending_info.get(
+ "part_index",
+ 9999,
+ )
+ - part_index
+ )
+ <= 3
+ ):
+ # Close enough - assume it's the result for this tool call
+ pending_info[
+ "result"
+ ] = tool_content
+ _tool_name = pending_info.get(
+ "tool_name",
+ "unknown",
+ )
+ _start_time = pending_info.get(
+ "start_time",
+ time_module.time(),
+ )
+ _duration_ms = (
+ time_module.time()
+ - _start_time
+ ) * 1000
+ _group_id = resolve_tool_group_id(
+ tool_id=pending_id,
+ pending_info=pending_info,
+ fallback_group_id=current_tool_group_id,
+ tool_name=_tool_name,
+ source="tool_return_proximity",
+ )
+ logger.info(
+ f"[WebSocket] ToolReturnPart: Sending result (by proximity) for {_tool_name} (id: {pending_id})"
+ )
+ # Send the REAL tool_result with actual content
+ await send_typed_tool_lifecycle(
+ ServerToolResult(
+ tool_id=pending_id,
+ tool_name=_tool_name,
+ result=tool_content,
+ success=True,
+ duration_ms=_duration_ms,
+ timestamp=time_module.time(),
+ session_id=session_id,
+ agent_name=current_agent_name
+ or "code-puppy",
+ model_name=current_model_name
+ or "unknown",
+ tool_group_id=_group_id,
+ )
+ )
+ _result_sent = True
+ break
+
+ # Warn if we couldn't send the result
+ if not _result_sent:
+ logger.warning(
+ f"[WebSocket] ToolReturnPart: Could NOT send result! tool_call_id={tool_call_id}, pending_tool_calls={list(pending_tool_calls.keys())}, part_index={part_index}"
+ )
+
+ # Track this part for cleanup
+ active_parts[part_index] = {
+ "id": f"tool-return-{part_index}",
+ "type": "tool_return",
+ "tool_call_id": tool_call_id,
+ "content": tool_content,
+ }
+
+ elif inner_type == "part_delta":
+ part_index = inner_data.get(
+ "index", 0
+ )
+ delta_type = inner_data.get(
+ "delta_type", ""
+ )
+ delta_obj = inner_data.get(
+ "delta", {}
+ )
+
+ # Handle ToolCallPartDelta - accumulate args
+ if (
+ delta_type
+ == "ToolCallPartDelta"
+ ):
+ args_delta = ""
+ if hasattr(
+ delta_obj, "args_delta"
+ ):
+ args_delta = (
+ delta_obj.args_delta
+ or ""
+ )
+ elif isinstance(
+ delta_obj, dict
+ ):
+ args_delta = (
+ delta_obj.get(
+ "args_delta", ""
+ )
+ or ""
+ )
+
+ if (
+ args_delta
+ and part_index
+ in active_parts
+ ):
+ part_info = active_parts[
+ part_index
+ ]
+ if (
+ part_info.get("type")
+ == "tool_call"
+ ):
+ part_info[
+ "args_buffer"
+ ] = (
+ part_info.get(
+ "args_buffer",
+ "",
+ )
+ + args_delta
+ )
+ continue # Don't process as text delta
+
+ # Extract content_delta (supports both direct and nested formats)
+ content_delta = inner_data.get(
+ "content_delta", ""
+ )
+ if not content_delta:
+ if isinstance(delta_obj, dict):
+ content_delta = (
+ delta_obj.get(
+ "content_delta", ""
+ )
+ )
+
+ if content_delta:
+ collected_text.append(
+ content_delta
+ )
+
+ # Ensure we have an entry for this part (handles delta before start)
+ if (
+ part_index
+ not in active_parts
+ ):
+ # Create entry with consistent message_id
+ message_id = f"msg-{int(time_module.time() * 1000)}-{part_index}"
+ active_parts[part_index] = {
+ "id": message_id,
+ "type": "text", # Default, will be updated by part_start if it arrives
+ "content": "",
+ }
+ # Send a start event so client creates the message
+ logger.debug(
+ f"[Stream Debug] Creating part on first delta: message_id={message_id}"
+ )
+ if part_index == 0:
+ current_tool_group_id = None
+ await safe_send_json(
+ ServerAssistantMessageStart(
+ message_id=message_id,
+ part_type="text",
+ part_index=part_index,
+ timestamp=time_module.time(),
+ session_id=session_id,
+ agent_name=current_agent_name,
+ model_name=current_model_name,
+ tool_name=current_tool_name,
+ ).model_dump(
+ exclude_none=True
+ )
+ )
+
+ part_info = active_parts[
+ part_index
+ ]
+ message_id = part_info["id"]
+
+ if part_index in active_parts:
+ active_parts[part_index][
+ "content"
+ ] += content_delta
+
+ _delta = ServerAssistantMessageDelta.model_construct(
+ type="assistant_message_delta",
+ message_id=message_id,
+ content=content_delta,
+ part_index=part_index,
+ session_id=session_id,
+ agent_name=current_agent_name,
+ model_name=current_model_name,
+ tool_name=current_tool_name,
+ )
+ await safe_send_json(
+ _delta.model_dump(
+ exclude_none=True
+ )
+ )
+ # Mark that B1 streaming was used
+ b1_streaming_used = True
+
+ elif inner_type == "part_end":
+ part_index = inner_data.get(
+ "index", 0
+ )
+ part_info = active_parts.get(
+ part_index, {}
+ )
+ part_type_info = part_info.get(
+ "type", "text"
+ )
+ message_id = part_info.get(
+ "id", f"msg-{part_index}"
+ )
+ full_content = part_info.get(
+ "content", ""
+ )
+
+ # Handle tool_call parts - send tool_call event now that args are complete
+ if part_type_info == "tool_call":
+ tool_name = part_info.get(
+ "tool_name", "unknown"
+ )
+ tool_id = part_info.get(
+ "id", str(uuid.uuid4())[:8]
+ )
+ tool_args_str = part_info.get(
+ "args_buffer", ""
+ ) or part_info.get("args", "")
+ start_time = part_info.get(
+ "start_time",
+ time_module.time(),
+ )
+ raw_tool_call_id = (
+ part_info.get(
+ "raw_tool_call_id"
+ )
+ )
+
+ # Parse args if they're a JSON string
+ try:
+ import json
+
+ args_dict = (
+ json.loads(
+ tool_args_str
+ )
+ if tool_args_str
+ else {}
+ )
+ except (
+ json.JSONDecodeError,
+ TypeError,
+ ):
+ args_dict = {}
+
+ logger.debug(
+ f"[WebSocket] Sending tool_call (args complete): {tool_name}"
+ )
+
+ # Generate tool group ID for this batch if not already set
+ if (
+ current_tool_group_id
+ is None
+ ):
+ current_tool_group_id = f"tg-{str(uuid.uuid4())[:8]}"
+
+ # Send tool_call now that we have complete args
+ await send_typed_tool_lifecycle(
+ ServerToolCall(
+ tool_id=tool_id,
+ tool_name=tool_name,
+ args=args_dict,
+ timestamp=time_module.time(),
+ session_id=session_id,
+ agent_name=current_agent_name,
+ model_name=current_model_name,
+ tool_group_id=current_tool_group_id,
+ )
+ )
+
+ # Track as pending tool call for later tool_result
+ pending_tool_calls[tool_id] = {
+ "tool_name": tool_name,
+ "start_time": start_time,
+ "part_index": part_index,
+ "raw_tool_call_id": raw_tool_call_id,
+ "status_only_sent": False,
+ "result": None, # Will be populated by ToolReturnPart
+ "tool_group_id": current_tool_group_id,
+ }
+ if raw_tool_call_id:
+ tool_id_aliases[
+ raw_tool_call_id
+ ] = tool_id
+ # Also track tool_group_id for pre-stream_end extraction
+ if current_tool_group_id:
+ tool_group_ids[tool_id] = (
+ current_tool_group_id
+ )
+
+ # Clear current tool name tracking
+ current_tool_name = None
+ else:
+ await safe_send_json(
+ ServerAssistantMessageEnd(
+ message_id=message_id,
+ part_index=part_index,
+ full_content=full_content,
+ timestamp=time_module.time(),
+ session_id=session_id,
+ agent_name=current_agent_name,
+ model_name=current_model_name,
+ tool_name=current_tool_name,
+ ).model_dump(
+ exclude_none=True
+ )
+ )
+
+ if part_index in active_parts:
+ del active_parts[part_index]
+
+ except Exception as send_err:
+ error_msg = str(send_err).lower()
+ if (
+ "close message" in error_msg
+ or "closed" in error_msg
+ ):
+ ws_closed = True
+ logger.debug(
+ "WebSocket closed during streaming, stopping drain"
+ )
+ break
+ logger.warning(
+ f"Error sending event to WebSocket: {type(send_err).__name__}: {send_err}"
+ )
+ import traceback
+
+ logger.warning(
+ f"Traceback: {traceback.format_exc()}"
+ )
+
+ # No idle polling - event-driven approach handles empty event sets gracefully
+
+ # Final drain after stop signal
+ final_count = 0
+ while True:
+ try:
+ event = event_queue.get_nowait()
+ event_type = event.get("type", "")
+ event_data = event.get("data", {})
+ final_count += 1
+
+ if event_type == "stream_event":
+ inner_type = event_data.get(
+ "event_type", ""
+ )
+ inner_data = event_data.get(
+ "event_data", {}
+ )
+ if inner_type == "part_delta":
+ content_delta = inner_data.get(
+ "content_delta", ""
+ )
+ if not content_delta:
+ delta_obj = inner_data.get(
+ "delta", {}
+ )
+ if isinstance(delta_obj, dict):
+ content_delta = delta_obj.get(
+ "content_delta", ""
+ )
+ if content_delta:
+ collected_text.append(content_delta)
+ except Exception:
+ break
+
+ # Calculate batching efficiency
+ avg_batch_size = (
+ event_count / max(1, event_count)
+ if event_count > 0
+ else 0
+ )
+ logger.debug(
+ f"[{session_id}] Drain complete: "
+ f"{event_count} events during run, {final_count} final, "
+ f"batching efficiency: {avg_batch_size:.2f}"
+ )
+
+ # Event to signal drain task is ready
+ drain_ready = asyncio.Event()
+
+ try:
+ from code_puppy.plugins.frontend_emitter.emitter import (
+ subscribe,
+ unsubscribe,
+ )
+
+ event_queue = subscribe(session_id=session_id)
+ logger.debug(
+ "Subscribed to frontend emitter for streaming"
+ )
+
+ # Modify drain function to signal when ready
+ async def drain_events_with_signal():
+ """Wrapper that signals readiness before starting drain loop."""
+ await drain_events_concurrent(drain_ready)
+
+ # Start concurrent drain task
+ drain_task = asyncio.create_task(
+ drain_events_with_signal()
+ )
+ active_drain_task = (
+ drain_task # Track for potential cancellation
+ )
+
+ # Wait for drain task to be ready before proceeding
+ await drain_ready.wait()
+
+ except ImportError:
+ logger.warning("Frontend emitter not available")
+
+ try:
+ # Send status: thinking
+ await send_typed(
+ ServerStatus(
+ status="thinking",
+ session_id=session_id,
+ agent_name=agent.name
+ if agent
+ else "code-puppy",
+ model_name=agent.get_model_name()
+ if agent
+ else "unknown",
+ )
+ )
+
+ # Change to session working directory if set
+ # Set session context for prompt generation (desk-puppy)
+ set_session_working_directory(session_working_directory)
+
+ try:
+ # Call run_with_mcp (drain task runs concurrently!)
+ logger.debug(
+ "About to run agent, working_directory=%s",
+ session_working_directory,
+ )
+
+ # Inject working directory as a system message in the
+ # agent's conversation history (once per directory change)
+ # so the LLM knows the CWD without polluting user messages.
+ message_to_send = user_message
+ if (
+ session_working_directory
+ and session_working_directory
+ != last_context_sent_directory
+ ):
+ from pydantic_ai.messages import (
+ ModelRequest,
+ SystemPromptPart,
+ )
+
+ wd_system_msg = ModelRequest(
+ parts=[
+ SystemPromptPart(
+ content=(
+ f"The user's current working directory is updated to"
+ f" {session_working_directory}"
+ )
+ )
+ ]
+ )
+ agent.append_to_message_history(wd_system_msg)
+ last_context_sent_directory = (
+ session_working_directory
+ )
+ logger.debug(
+ "Injected working directory system message: %s",
+ session_working_directory,
+ )
+
+ logger.debug(
+ f"Calling run_with_mcp with message: {message_to_send[:100]}..."
+ )
+
+ # Build file context and binary attachments
+ file_context, binary_attachments = (
+ build_file_context_and_attachments(msg)
+ )
+
+ # CHANGE 1: Update attachment metadata for UI (variables already initialized)
+ if msg.get("attachments"):
+ from pathlib import Path as _AttachmentPath
+
+ for raw_path in msg.get("attachments", []):
+ if (
+ isinstance(raw_path, str)
+ and raw_path.strip()
+ ):
+ try:
+ file_path = _AttachmentPath(
+ raw_path
+ )
+ if file_path.exists():
+ attachment_metadata.append(
+ {
+ "name": file_path.name,
+ "path": str(
+ file_path.absolute()
+ ),
+ "sizeBytes": file_path.stat().st_size,
+ }
+ )
+ except Exception as e:
+ logger.warning(
+ f"Error building attachment metadata for '{raw_path}': {e}"
+ )
+
+ # Prepend file context to the message
+ if file_context:
+ message_to_send = (
+ file_context + "\n\n" + message_to_send
+ )
+ logger.debug(
+ f"Added file context ({len(file_context)} chars)"
+ )
+
+ run_kwargs = {}
+ if binary_attachments:
+ run_kwargs["attachments"] = binary_attachments
+ logger.debug(
+ f"Including {len(binary_attachments)} binary attachment(s)"
+ )
+
+ # ──────────────────────────────────────────────────────────────────
+ # Phase 7: Create session + user message in SQLite BEFORE streaming
+ # This prevents "Session not found" errors when FE queries mid-stream
+ # ──────────────────────────────────────────────────────────────────
+ try:
+ import os
+ from datetime import timezone as tz
+
+ from code_puppy.api.db.queries import (
+ upsert_session,
+ )
+
+ now_iso = datetime.datetime.now(
+ tz.utc
+ ).isoformat()
+
+ # Ensure session row exists
+ await upsert_session(
+ session_id=session_id,
+ title="", # Will be updated later with actual title
+ agent_name=ctx.agent_name,
+ model_name=ctx.model_name,
+ working_directory=os.getcwd(),
+ pinned=False,
+ created_at=now_iso,
+ updated_at=now_iso,
+ message_count=0, # Will be updated after streaming
+ total_tokens=0,
+ )
+
+ # Write user message immediately (before agent processes it)
+ # Use get_next_seq() + insert_message() so the user message
+ # lands at MAX(seq)+1, AFTER any system messages (config,
+ # directory banners) that were already written at seq=1, 2, …
+ # The old write_turn_to_sqlite([single_item]) always assigned
+ # seq=1, silently colliding with those rows via INSERT OR IGNORE.
+ from pydantic_ai.messages import (
+ ModelRequest,
+ UserPromptPart,
+ )
+
+ from code_puppy.api.db.message_utils import (
+ pydantic_json_for_message,
+ )
+ from code_puppy.api.db.queries import (
+ get_next_seq,
+ insert_message,
+ )
+
+ user_msg_obj = ModelRequest(
+ parts=[
+ UserPromptPart(
+ content=original_user_message
+ )
+ ]
+ )
+
+ user_seq = await get_next_seq(session_id)
+ await insert_message(
+ session_id=session_id,
+ seq=user_seq,
+ role="user",
+ content=original_user_message,
+ type="ModelRequest",
+ agent_name=ctx.agent_name,
+ model_name=ctx.model_name,
+ timestamp=now_iso,
+ clean_content=original_user_message,
+ attachments_json=(
+ json.dumps(attachment_metadata)
+ if attachment_metadata
+ else None
+ ),
+ pydantic_json=pydantic_json_for_message(
+ user_msg_obj
+ ),
+ )
+
+ logger.debug(
+ "Pre-stream write: session %s created with user message in SQLite",
+ session_id,
+ )
+ except Exception as pre_write_exc:
+ # Non-fatal: WS streaming will still work, SQLite just won't have the
+ # session yet. The post-stream write will create it.
+ logger.warning(
+ "Pre-stream SQLite write failed for %s: %s",
+ session_id,
+ pre_write_exc,
+ exc_info=True,
+ )
+
+ # Run the agent in its own task so it can be cancelled independently.
+ # Suppress duplicate emitter lifecycle callbacks only during the
+ # run_with_mcp execution window (pre/post_tool_call hooks fire there).
+ set_suppress_emitter_tool_events(True)
+ active_agent_task = asyncio.create_task(
+ agent.run_with_mcp(
+ message_to_send, **run_kwargs
+ )
+ )
+
+ # ===== CONCURRENT MESSAGE PROCESSING FIX =====
+ # Instead of awaiting the agent task here (which blocks the receive loop),
+ # we use asyncio.wait() to handle BOTH the agent task AND incoming messages.
+ # This allows permission_response messages to be processed while the agent runs.
+
+ result = None
+ agent_completed = False
+ # Deferred message for switch_session/create_session during streaming
+ _deferred_msg: dict | None = None
+
+ while not agent_completed:
+ # Create a task for the next message (or wait for agent)
+ receive_task = asyncio.create_task(
+ websocket.receive_json()
+ )
+
+ # Wait for either the agent to complete OR a new message to arrive
+ done, pending = await asyncio.wait(
+ {active_agent_task, receive_task},
+ return_when=asyncio.FIRST_COMPLETED,
+ )
+
+ # Check if agent completed
+ if active_agent_task in done:
+ try:
+ result = await active_agent_task
+ logger.debug(
+ f"run_with_mcp completed, result type: {type(result)}"
+ )
+ agent_completed = True
+ active_agent_task = None
+ except asyncio.CancelledError:
+ logger.debug(
+ "run_with_mcp task was cancelled by user"
+ )
+ agent_error = "cancelled"
+ result = None
+ agent_completed = True
+ active_agent_task = None
+ except Exception as e:
+ logger.error(
+ "Agent task error: %s",
+ e,
+ exc_info=True,
+ )
+ logger.debug(
+ "[WS:%s] agent task exception captured: type=%s repr=%r",
+ session_id,
+ type(e).__name__,
+ e,
+ )
+ agent_error = e
+ result = None
+ agent_completed = True
+ active_agent_task = None
+
+ # Cancel the receive task if it's still pending
+ if receive_task in pending:
+ receive_task.cancel()
+ try:
+ await receive_task
+ except asyncio.CancelledError:
+ pass
+
+ # Check if a new message arrived
+ elif receive_task in done:
+ try:
+ new_msg = await receive_task
+
+ # Advisory validation — log but never reject
+ try:
+ _parsed_inner = _ClientMessageAdapter.validate_python(
+ new_msg
+ )
+ except ValidationError as _val_err:
+ logger.warning(
+ "Client message failed validation: %s",
+ str(_val_err),
+ extra={
+ "type": new_msg.get("type")
+ if isinstance(new_msg, dict)
+ else "unknown"
+ },
+ )
+
+ # Handle permission_response immediately
+ if (
+ new_msg.get("type")
+ == "permission_response"
+ ):
+ from code_puppy.api.permissions import (
+ handle_permission_response,
+ )
+
+ request_id = new_msg.get(
+ "request_id"
+ )
+ approved = new_msg.get(
+ "approved", False
+ )
+
+ if request_id:
+ handled = (
+ handle_permission_response(
+ request_id, approved
+ )
+ )
+ if handled:
+ logger.debug(
+ f"[Permission] ✅ Handled response: {request_id} = {approved}"
+ )
+ else:
+ logger.warning(
+ f"[Permission] ❌ Unknown request: {request_id}"
+ )
+ else:
+ logger.error(
+ "[WebSocket] ❌ No request_id in permission_response!"
+ )
+
+ # Handle cancel request
+ elif new_msg.get("type") == "cancel":
+ logger.debug(
+ "Cancel request received during agent execution"
+ )
+ if (
+ active_agent_task
+ and not active_agent_task.done()
+ ):
+ active_agent_task.cancel()
+ agent_completed = (
+ True # Exit the loop
+ )
+
+ # Handle session switch during streaming
+ elif new_msg.get("type") in (
+ "switch_session",
+ "create_session",
+ ):
+ # User switched to a new chat while agent was running.
+ # The agent will finish in background and save to SQLite.
+ logger.debug(
+ "[WS:%s] Session switch during streaming — "
+ "agent continues in background, switching to: %s",
+ session_id,
+ new_msg.get("session_id"),
+ )
+
+ # Fire and forget — background task owns the agent result.
+ fire_and_track(
+ save_agent_result_in_background(
+ agent_task=active_agent_task,
+ session_id=session_id,
+ ctx=ctx,
+ agent=agent,
+ agent_name=agent_name,
+ model_name=model_name,
+ title=session_title,
+ working_directory=session_working_directory,
+ pinned=session_pinned,
+ label="switch",
+ )
+ )
+
+ _deferred_msg = new_msg
+ active_agent_task = None # disown — background task now owns it
+ agent_completed = (
+ True # exit inner loop
+ )
+
+ # For other message types, log and ignore (or queue for later)
+ else:
+ logger.warning(
+ f"[WebSocket] Received {new_msg.get('type')} message while agent running - ignoring"
+ )
+
+ except asyncio.CancelledError:
+ # Receive was cancelled, continue
+ pass
+ except WebSocketDisconnect:
+ # WebSocket gone — let agent finish and save to SQLite
+ logger.debug(
+ "[WS:%s] Disconnect during streaming — agent continues in background",
+ session_id,
+ )
+
+ # Fire and forget — background task owns the agent result.
+ fire_and_track(
+ save_agent_result_in_background(
+ agent_task=active_agent_task,
+ session_id=session_id,
+ ctx=ctx,
+ agent=agent,
+ agent_name=agent_name,
+ model_name=model_name,
+ title=session_title,
+ working_directory=session_working_directory,
+ pinned=session_pinned,
+ label="disconnect",
+ )
+ )
+ active_agent_task = None
+ agent_completed = True
+ except RuntimeError as e:
+ if "disconnect" in str(e).lower():
+ # Starlette raises RuntimeError when calling receive after disconnect
+ logger.debug(
+ "[WS:%s] WebSocket already disconnected: %s — agent continues in background",
+ session_id,
+ e,
+ )
+
+ # Fire and forget — background task owns the agent result.
+ fire_and_track(
+ save_agent_result_in_background(
+ agent_task=active_agent_task,
+ session_id=session_id,
+ ctx=ctx,
+ agent=agent,
+ agent_name=agent_name,
+ model_name=model_name,
+ title=session_title,
+ working_directory=session_working_directory,
+ pinned=session_pinned,
+ label="runtime",
+ )
+ )
+ active_agent_task = None
+ agent_completed = True
+ else:
+ logger.error(
+ f"RuntimeError processing message during agent execution: {e}"
+ )
+ except Exception as e:
+ logger.error(
+ f"Error processing message during agent execution: {e}"
+ )
+
+ # ===== END CONCURRENT MESSAGE PROCESSING FIX =====
+
+ finally:
+ # End suppression scope once run_with_mcp window ends,
+ # including completion/cancel/background handoff paths.
+ set_suppress_emitter_tool_events(False)
+ # Clear session context for prompt generation
+ clear_session_working_directory()
+
+ finally:
+ # Stop the drain task
+ if drain_task:
+ stop_draining.set()
+ try:
+ await asyncio.wait_for(drain_task, timeout=2.0)
+ except asyncio.TimeoutError:
+ drain_task.cancel()
+ try:
+ await drain_task
+ except asyncio.CancelledError:
+ pass
+
+ # Unsubscribe from emitter
+ if event_queue:
+ unsubscribe(event_queue)
+ logger.debug("Unsubscribed from frontend emitter")
+
+ # Process any deferred switch_session that arrived during streaming
+ if _deferred_msg is not None:
+ msg = _deferred_msg
+ _deferred_msg = None
+ # Re-dispatch to outer loop by continuing with msg set
+ # The outer while True loop will handle switch_session/create_session
+ continue
+
+ # If agent errored, send error to GUI and skip success path
+ if agent_error == "cancelled":
+ await send_typed(
+ ServerCancelled(
+ session_id=session_id,
+ )
+ )
+ continue
+ elif agent_error is not None:
+ logger.debug(
+ "[WS:%s] agent_error -> sending frame(s) to client. type=%s",
+ session_id,
+ type(agent_error).__name__,
+ )
+ # If streaming was already in progress, send stream_end first so
+ # the frontend exits its streaming state before the error frame.
+ # Without this handshake the UI hangs indefinitely.
+ for frame in build_error_response_frames(
+ agent_error, collected_text, session_id
+ ):
+ await safe_send_json(frame)
+ continue
+
+ # Safety net: if the agent task completed without raising but returned
+ # no result and produced no streamed text, treat it as an error.
+ has_nonempty_stream = any(
+ (chunk or "").strip() for chunk in collected_text
+ )
+ logger.debug(
+ "[WS:%s] post-run: result_is_none=%s collected_chunks=%d has_nonempty_stream=%s",
+ session_id,
+ result is None,
+ len(collected_text),
+ has_nonempty_stream,
+ )
+
+ if result is None and not has_nonempty_stream:
+ logger.warning(
+ "[WS:%s] Agent task completed with result=None and no streamed text; treating as error.",
+ session_id,
+ )
+ parsed_error = parse_api_error(
+ RuntimeError(
+ "Agent run failed (no result returned). Check server logs for the underlying exception."
+ )
+ )
+ await send_typed(
+ ServerError(
+ error=parsed_error["user_message"],
+ error_type=parsed_error["error_type"],
+ technical_details=parsed_error[
+ "technical_details"
+ ],
+ action_required=parsed_error.get(
+ "action_required"
+ ),
+ session_id=session_id,
+ )
+ )
+ continue
+
+ # Extract final response text
+ response_text = ""
+
+ # Priority 1: Use collected text from streaming events
+ if has_nonempty_stream:
+ response_text = "".join(collected_text)
+ logger.debug(
+ f"Using collected streaming text ({len(response_text)} chars)"
+ )
+ # Priority 2: Extract from result.output (AgentRunResult) or result.data (legacy)
+ elif result:
+ if hasattr(result, "output"):
+ response_text = (
+ str(result.output)
+ if result.output
+ else "(Empty response)"
+ )
+ logger.debug(
+ f"Using result.output ({len(response_text)} chars)"
+ )
+ elif hasattr(result, "data"):
+ response_text = (
+ str(result.data)
+ if result.data
+ else "(Empty response)"
+ )
+ logger.debug(
+ f"Using result.data ({len(response_text)} chars)"
+ )
+ # Priority 3: Get last assistant message from history
+ elif agent:
+ messages = agent.get_message_history()
+
+ for msg in reversed(messages):
+ if hasattr(msg, "role") and msg.role == "assistant":
+ if hasattr(msg, "content"):
+ response_text = str(msg.content)
+ logger.debug(
+ f"Using message history ({len(response_text)} chars)"
+ )
+
+ break
+
+ if not response_text:
+ response_text = "Agent returned no response"
+
+ # Extract token usage from result if available
+ tokens_used = None
+ if result:
+ # Try to get usage from result
+ if hasattr(result, "usage"):
+ usage = result.usage
+ if usage:
+ tokens_used = {
+ "input_tokens": getattr(
+ usage, "input_tokens", None
+ )
+ or getattr(usage, "prompt_tokens", None),
+ "output_tokens": getattr(
+ usage, "output_tokens", None
+ )
+ or getattr(
+ usage, "completion_tokens", None
+ ),
+ "total_tokens": getattr(
+ usage, "total_tokens", None
+ ),
+ }
+ # Also try _usage or other common patterns
+ elif hasattr(result, "_usage"):
+ usage = result._usage
+ if usage:
+ tokens_used = {
+ "input_tokens": getattr(
+ usage, "input_tokens", None
+ )
+ or getattr(usage, "prompt_tokens", None),
+ "output_tokens": getattr(
+ usage, "output_tokens", None
+ )
+ or getattr(
+ usage, "completion_tokens", None
+ ),
+ "total_tokens": getattr(
+ usage, "total_tokens", None
+ ),
+ }
+
+ # If we couldn't get usage from result, estimate from message history
+ if not tokens_used and agent:
+ try:
+ history = agent.get_message_history()
+ total_estimated = sum(
+ agent.estimate_tokens_for_message(msg)
+ for msg in history
+ )
+ tokens_used = {
+ "total_tokens": total_estimated,
+ "estimated": True,
+ }
+ except Exception:
+ pass
+
+ # Only send legacy 'response' if B1 streaming wasn't used
+ # B1 streaming already sent the content via assistant_message_end
+ logger.warning(
+ "[WebSocket] b1_streaming_used=%s before response/extraction",
+ b1_streaming_used,
+ )
+ if not b1_streaming_used:
+ # For non-streaming models (like Gemini), extract and send thinking content first
+ thinking_text = ""
+ if agent:
+ try:
+ history = agent.get_message_history()
+ logger.debug(
+ f"[Thinking Debug] Checking history for thinking parts, {len(history) if history else 0} messages"
+ )
+ if history:
+ # Look for ThinkingPart in the last ModelResponse
+ for i, msg in enumerate(reversed(history)):
+ msg_type = type(msg).__name__
+ logger.debug(
+ f"[Thinking Debug] Message {i}: type={msg_type}, has_parts={hasattr(msg, 'parts')}"
+ )
+ if "Response" in msg_type and hasattr(
+ msg, "parts"
+ ):
+ logger.debug(
+ f"[Thinking Debug] Found Response with {len(msg.parts)} parts"
+ )
+ for j, part in enumerate(msg.parts):
+ part_type = type(part).__name__
+ part_content_preview = (
+ str(
+ getattr(
+ part, "content", ""
+ )
+ )[:100]
+ if hasattr(part, "content")
+ else "N/A"
+ )
+ logger.debug(
+ f"[Thinking Debug] Part {j}: type={part_type}, content_preview={part_content_preview}"
+ )
+ if (
+ "Thinking" in part_type
+ and hasattr(part, "content")
+ ):
+ thinking_text = part.content
+ logger.debug(
+ f"[Thinking Debug] Found thinking content: {len(thinking_text)} chars"
+ )
+ break
+ if thinking_text:
+ break
+ except Exception as e:
+ logger.warning(
+ f"Could not extract thinking content: {e}"
+ )
+ import traceback
+
+ logger.warning(traceback.format_exc())
+
+ if not thinking_text:
+ logger.debug(
+ "[Thinking Debug] No thinking content found in message history"
+ )
+
+ # Send thinking content as B1 message if available
+ if thinking_text:
+ import time as time_module
+
+ thinking_message_id = f"thinking-{session_id}-{int(time_module.time() * 1000)}"
+ await send_typed(
+ ServerAssistantMessageStart(
+ message_id=thinking_message_id,
+ part_type="thinking",
+ part_index=0,
+ timestamp=time_module.time(),
+ session_id=session_id,
+ agent_name=agent.name
+ if agent
+ else "code-puppy",
+ model_name=agent.get_model_name()
+ if agent
+ else "unknown",
+ )
+ )
+ _delta = (
+ ServerAssistantMessageDelta.model_construct(
+ type="assistant_message_delta",
+ message_id=thinking_message_id,
+ content=thinking_text,
+ part_index=0,
+ session_id=session_id,
+ agent_name=agent.name
+ if agent
+ else "code-puppy",
+ model_name=agent.get_model_name()
+ if agent
+ else "unknown",
+ )
+ )
+ await safe_send_json(
+ _delta.model_dump(exclude_none=True)
+ )
+ await send_typed(
+ ServerAssistantMessageEnd(
+ message_id=thinking_message_id,
+ part_index=0,
+ full_content=thinking_text,
+ timestamp=time_module.time(),
+ session_id=session_id,
+ agent_name=agent.name
+ if agent
+ else "code-puppy",
+ model_name=agent.get_model_name()
+ if agent
+ else "unknown",
+ )
+ )
+ logger.debug(
+ f"Sent thinking content ({len(thinking_text)} chars) for non-streaming model"
+ )
+
+ # Send the main response
+ await send_typed(
+ ServerResponse(
+ content=response_text,
+ done=True,
+ session_id=session_id,
+ agent_name=agent.name
+ if agent
+ else "code-puppy",
+ model_name=agent.get_model_name()
+ if agent
+ else "unknown",
+ tokens=tokens_used,
+ )
+ )
+ else:
+ # B1 streaming: extract real tool results BEFORE stream_end
+ # so the frontend session store is still alive when they arrive.
+ _pre_sent_tool_ids: set = set()
+ logger.warning(
+ "[WebSocket] B1 Pre-stream_end extraction: result=%s, has_all_messages=%s",
+ type(result).__name__ if result else None,
+ hasattr(result, "all_messages")
+ if result
+ else False,
+ )
+ if result and hasattr(result, "all_messages"):
+ try:
+ import time as _time_pre
+
+ from pydantic_ai.messages import (
+ ToolReturn,
+ ToolReturnPart,
+ )
+
+ _pre_msgs = list(result.all_messages())
+ for _pre_msg in _pre_msgs:
+ if not hasattr(_pre_msg, "parts"):
+ continue
+ for _pre_part in _pre_msg.parts:
+ if not isinstance(
+ _pre_part,
+ (ToolReturnPart, ToolReturn),
+ ):
+ continue
+ _pre_tool_name = getattr(
+ _pre_part, "tool_name", "unknown"
+ )
+ _pre_raw_tool_id = getattr(
+ _pre_part, "tool_call_id", None
+ )
+ _pre_tool_id = (
+ tool_id_aliases.get(
+ _pre_raw_tool_id,
+ _pre_raw_tool_id,
+ )
+ if _pre_raw_tool_id
+ else "unknown"
+ )
+ _pre_result = getattr(
+ _pre_part, "content", None
+ )
+ # Serialize complex objects
+ if _pre_result and not isinstance(
+ _pre_result,
+ (
+ str,
+ dict,
+ list,
+ int,
+ float,
+ bool,
+ type(None),
+ ),
+ ):
+ try:
+ if hasattr(
+ _pre_result, "model_dump"
+ ):
+ _pre_result = (
+ _pre_result.model_dump()
+ )
+ elif hasattr(
+ _pre_result, "dict"
+ ):
+ _pre_result = (
+ _pre_result.dict()
+ )
+ elif hasattr(
+ _pre_result, "__dict__"
+ ):
+ _pre_result = (
+ _pre_result.__dict__
+ )
+ else:
+ _pre_result = str(
+ _pre_result
+ )
+ except Exception:
+ _pre_result = str(_pre_result)
+ logger.warning(
+ "[WebSocket] Pre-stream_end tool result: %s (id: %s), content_preview=%s",
+ _pre_tool_name,
+ _pre_tool_id,
+ str(_pre_result)[:100]
+ if _pre_result
+ else None,
+ )
+ _pre_group_id = tool_group_ids.get(
+ _pre_tool_id
+ )
+
+ await send_typed_tool_lifecycle(
+ ServerToolResult(
+ tool_id=_pre_tool_id,
+ tool_name=_pre_tool_name,
+ result=_pre_result,
+ success=True,
+ duration_ms=0,
+ timestamp=_time_pre.time(),
+ session_id=session_id,
+ agent_name=agent.name
+ if agent
+ else "code-puppy",
+ model_name=agent.get_model_name()
+ if agent
+ else "unknown",
+ tool_group_id=_pre_group_id,
+ )
+ )
+ _pre_sent_tool_ids.add(_pre_tool_id)
+ except Exception as _pre_e:
+ logger.warning(
+ "Pre-stream_end tool result extraction failed: %s",
+ _pre_e,
+ )
+
+ # Send stream_end AFTER real tool results are delivered
+ await send_typed(
+ ServerStreamEnd(
+ success=True,
+ total_length=len(response_text),
+ agent_name=agent.name
+ if agent
+ else "code-puppy",
+ model_name=agent.get_model_name()
+ if agent
+ else "unknown",
+ tokens=tokens_used,
+ session_id=session_id,
+ )
+ )
+
+ # Save session after each response
+ # Track tool IDs already sent before stream_end to skip duplicates later
+ if "_pre_sent_tool_ids" not in dir():
+ _pre_sent_tool_ids: set = set()
+ _save_history_snapshot = [] # Snapshot of history before await points
+ try:
+ # CRITICAL: Update message history from result to include final response
+ # The pydantic-ai result.all_messages() contains the complete conversation
+ # including the final assistant response that may not be in agent's history yet
+ if result and hasattr(result, "all_messages"):
+ try:
+ all_msgs = list(result.all_messages())
+ if all_msgs:
+ agent.set_message_history(all_msgs)
+ _save_history_snapshot = list(
+ agent.get_message_history()
+ ) # Snapshot before any awaits can corrupt shared global state
+ logger.debug(
+ f"Updated message history from result.all_messages(): {len(all_msgs)} messages"
+ )
+
+ # Extract and send tool results from message history
+ try:
+ import time as _time
+
+ from pydantic_ai.messages import (
+ ToolReturn,
+ ToolReturnPart,
+ )
+
+ for msg in all_msgs:
+ if hasattr(msg, "parts"):
+ for part in msg.parts:
+ if isinstance(
+ part,
+ (
+ ToolReturnPart,
+ ToolReturn,
+ ),
+ ):
+ tool_name = getattr(
+ part,
+ "tool_name",
+ "unknown",
+ )
+ tool_call_id = getattr(
+ part,
+ "tool_call_id",
+ "unknown",
+ )
+ # Serialize the result
+ result_data = getattr(
+ part, "content", None
+ )
+ if (
+ result_data
+ and not isinstance(
+ result_data,
+ (
+ str,
+ dict,
+ list,
+ int,
+ float,
+ bool,
+ type(None),
+ ),
+ )
+ ):
+ try:
+ if hasattr(
+ result_data,
+ "model_dump",
+ ):
+ result_data = result_data.model_dump()
+ elif hasattr(
+ result_data,
+ "dict",
+ ):
+ result_data = result_data.dict()
+ elif hasattr(
+ result_data,
+ "__dict__",
+ ):
+ result_data = result_data.__dict__
+ else:
+ result_data = str(
+ result_data
+ )
+ except Exception:
+ result_data = str(
+ result_data
+ )
+
+ # Log for shell commands
+ if (
+ tool_name
+ == "agent_run_shell_command"
+ ):
+ stdout_val = (
+ result_data.get(
+ "stdout", "N/A"
+ )
+ if isinstance(
+ result_data,
+ dict,
+ )
+ else "not dict"
+ )
+ logger.debug(
+ "Extracted shell result: id=%s, stdout=%s",
+ tool_call_id,
+ stdout_val,
+ )
+
+ # Skip tool IDs already sent before stream_end
+ if (
+ tool_call_id
+ in _pre_sent_tool_ids
+ ):
+ logger.debug(
+ "[WebSocket] Skipping duplicate tool result (pre-sent): %s",
+ tool_call_id,
+ )
+ continue
+ logger.info(
+ f"[WebSocket] Sending extracted tool result for {tool_name} (id: {tool_call_id})"
+ )
+ _post_group_id = (
+ tool_group_ids.get(
+ tool_call_id
+ )
+ )
+
+ await send_typed(
+ ServerToolResult(
+ tool_id=tool_call_id,
+ tool_name=tool_name,
+ result=result_data,
+ success=True,
+ duration_ms=0,
+ timestamp=_time.time(),
+ session_id=session_id,
+ agent_name=agent.name
+ if agent
+ else "code-puppy",
+ model_name=agent.get_model_name()
+ if agent
+ else "unknown",
+ tool_group_id=_post_group_id,
+ )
+ )
+ except Exception as e:
+ logger.warning(
+ f"Could not extract tool results from messages: {e}"
+ )
+
+ except Exception as e:
+ logger.warning(
+ f"Could not update history from result.all_messages(): {e}"
+ )
+
+ history = (
+ _save_history_snapshot
+ if _save_history_snapshot
+ else agent.get_message_history()
+ ) # Use pre-await snapshot to avoid race condition
+ if history:
+ # Regenerate title if it's empty or still the default "untitled-session"
+ if (
+ not session_title
+ or session_title == "untitled-session"
+ ):
+ session_title = generate_heuristic_title(
+ history
+ )
+
+ # Session name is just the ID (WS_session_timestamp format)
+ # Title is stored only in the meta file, not in the filename
+ session_name = session_id
+
+ # Wrap each message with metadata for complete session information.
+ # Chain `or` fallbacks: agent.name can be "" before the agent
+ # object is fully configured, so we cascade to context defaults.
+ agent_name_meta = (
+ (agent.name if agent else "")
+ or ctx.agent_name
+ or "code-puppy"
+ )
+ model_name_meta = (
+ (agent.get_model_name() if agent else "")
+ or ctx.model_name
+ or "unknown"
+ )
+
+ def _extract_message_timestamp(
+ raw_msg: Any, default_ts: str
+ ) -> str:
+ """Best-effort extraction of an existing timestamp for a message.
+
+ This is important for WebSocket sessions where history may
+ already contain older messages. We don't want to overwrite
+ their original timestamps every time we auto-save.
+
+ Precedence:
+ 1. If the message is a dict with a numeric epoch 'timestamp',
+ convert to ISO.
+ 2. If the message is a dict with an ISO-ish 'timestamp' str,
+ reuse it as-is.
+ 3. If the message is a dict with 'ts', reuse it.
+ 4. If the message object has a 'timestamp' attribute, try that.
+ 5. Fall back to the provided default_ts ("now" for new messages).
+ """
+ # Dict-based histories (e.g. CLI format or older WS formats)
+ if isinstance(raw_msg, dict):
+ ts_val = raw_msg.get("timestamp")
+
+ # Epoch seconds
+ if isinstance(ts_val, (int, float)):
+ try:
+ return (
+ datetime.datetime.fromtimestamp(
+ ts_val
+ ).isoformat()
+ )
+ except Exception:
+ pass
+
+ # Already an ISO string
+ if isinstance(ts_val, str) and ts_val:
+ return ts_val
+
+ # Our enhanced WS wrapper sometimes uses 'ts'
+ ts_field = raw_msg.get("ts")
+ if isinstance(ts_field, str) and ts_field:
+ return ts_field
+
+ # Pydantic / custom objects that carry a timestamp attribute
+ try:
+ attr_ts = getattr(
+ raw_msg, "timestamp", None
+ )
+ if isinstance(attr_ts, (int, float)):
+ return datetime.datetime.fromtimestamp(
+ attr_ts
+ ).isoformat()
+ if isinstance(attr_ts, str) and attr_ts:
+ return attr_ts
+ except Exception:
+ pass
+
+ # Fallback: use provided default
+ return default_ts
+
+ # Create enhanced history: list of dicts with message + metadata
+ # Each entry: {'msg': , 'agent': str, 'model': str, 'ts': str}
+ enhanced_history = []
+ for idx, msg in enumerate(history):
+ # Check if already wrapped (for idempotency). If the wrapper
+ # already has a 'ts', leave it untouched so older sessions
+ # keep their original per-message timestamps.
+ if (
+ isinstance(msg, dict)
+ and "msg" in msg
+ and "agent" in msg
+ ):
+ enhanced_history.append(msg)
+ else:
+ # Use existing timestamp if present; otherwise compute a default now()
+ current_timestamp = (
+ datetime.datetime.now().isoformat()
+ )
+ msg_ts = _extract_message_timestamp(
+ msg, current_timestamp
+ )
+ wrapper = {
+ "msg": msg,
+ "agent": agent_name_meta,
+ "model": model_name_meta,
+ "ts": msg_ts,
+ }
+
+ # Add clean_content and attachments to the user message we just processed.
+ # clean_content is only needed when attachments were injected into content
+ # (file blocks like --- File: auth.ts ---). Without attachments, content
+ # is already the user's words (FE strips [Session Context:] as fallback).
+ is_user_message_just_processed = (
+ idx == len(history) - 2
+ and len(history) >= 2
+ and attachment_metadata # only needed when file content was injected
+ )
+
+ if is_user_message_just_processed:
+ wrapper["clean_content"] = (
+ original_user_message
+ )
+ wrapper["attachments"] = (
+ attachment_metadata
+ )
+ logger.debug(
+ "Added UI metadata to user message: %d attachment(s), "
+ "clean_content length: %d",
+ len(attachment_metadata),
+ len(original_user_message),
+ )
+
+ enhanced_history.append(wrapper)
+
+ message_count = len(enhanced_history)
+ total_tokens = 0
+ try:
+ for item in enhanced_history:
+ msg_obj = (
+ item["msg"]
+ if isinstance(item, dict)
+ and "msg" in item
+ else item
+ )
+ total_tokens += (
+ agent.estimate_tokens_for_message(
+ msg_obj
+ )
+ )
+ except Exception:
+ total_tokens = 0
+
+ # Write to SQLite for FE read path
+ try:
+ import datetime as _dt_mod
+
+ _now_iso = _dt_mod.datetime.now(
+ _dt_mod.timezone.utc
+ ).isoformat()
+ await write_turn_to_sqlite(
+ session_id=session_id,
+ enhanced_history=enhanced_history,
+ title=session_title,
+ working_directory=session_working_directory,
+ pinned=session_pinned,
+ agent_name=agent_name,
+ model_name=model_name,
+ total_tokens=total_tokens,
+ updated_at=_now_iso,
+ created_at=ctx.created_at.isoformat(),
+ ctx=ctx,
+ )
+ except Exception as _db_exc:
+ logger.debug(
+ "SQLite turn write skipped (DB not available): %s",
+ _db_exc,
+ )
+
+ # Send session metadata update to client
+ await websocket.send_json(
+ {
+ "type": "session_meta",
+ "session_id": session_id,
+ "session_name": session_name,
+ "total_tokens": total_tokens,
+ "message_count": message_count,
+ "title": session_title,
+ "working_directory": session_working_directory,
+ "agent_name": agent_name,
+ "model_name": model_name,
+ }
+ )
+
+ # Broadcast session update to session monitoring clients
+ session_update_data = {
+ "session_id": session_id,
+ "session_name": session_name,
+ "title": session_title,
+ "working_directory": session_working_directory,
+ "timestamp": datetime.datetime.now().isoformat(),
+ "message_count": message_count,
+ "total_tokens": total_tokens,
+ "auto_saved": True,
+ "pickle_path": "",
+ "metadata_path": "",
+ "action": "created"
+ if message_count == 1
+ else "updated",
+ }
+ await connection_manager.broadcast_session_update(
+ session_update_data
+ )
+ except Exception as save_err:
+ logger.warning(
+ f"Failed to save WebSocket session: {save_err}"
+ )
+
+ # Send final status to signal completion
+ await send_typed(
+ ServerStatus(
+ status="done",
+ session_id=session_id,
+ agent_name=agent.name if agent else "code-puppy",
+ model_name=agent.get_model_name()
+ if agent
+ else "unknown",
+ )
+ )
+
+ except Exception as e:
+ logger.error(
+ f"Error processing message: {e}", exc_info=True
+ )
+
+ # Parse error and send user-friendly message
+ parsed_error = parse_api_error(e)
+ _err_msg = ServerError(
+ error=parsed_error["user_message"],
+ error_type=parsed_error["error_type"],
+ technical_details=parsed_error["technical_details"],
+ action_required=parsed_error.get("action_required"),
+ session_id=session_id,
+ )
+ error_payload = _err_msg.model_dump(exclude_none=True)
+ await persist_error_payload(error_payload)
+ await send_typed(_err_msg)
+
+ except WebSocketDisconnect:
+ break
+ except RuntimeError as e:
+ # Handle disconnect-related RuntimeErrors (starlette raises these)
+ if (
+ "disconnect" in str(e).lower()
+ or "websocket.close" in str(e).lower()
+ ):
+ logger.debug("WebSocket disconnected (RuntimeError): %s", e)
+ break
+ # Re-raise other RuntimeErrors to be handled as generic exceptions
+ raise
+ except Exception as e:
+ logger.error("Chat WebSocket error: %s", e, exc_info=True)
+ # Don't try to send error if websocket is already closed
+ if ws_closed:
+ break
+ try:
+ _err_msg = ServerError(
+ error=str(e),
+ error_type="unknown",
+ technical_details=str(e),
+ session_id=session_id,
+ )
+ error_payload = _err_msg.model_dump(exclude_none=True)
+ await persist_error_payload(error_payload)
+ await send_typed(_err_msg)
+ except Exception:
+ break
+
+ except WebSocketDisconnect:
+ ws_closed = True
+ logger.debug("Chat WebSocket client disconnected")
+ except Exception as e:
+ logger.error("Chat WebSocket error: %s", e, exc_info=True)
+ finally:
+ logger.debug("Chat session %s ended", session_id)
+
+ # --- SESSION ISOLATION CLEANUP ---
+ # Save and tear down session-scoped resources
+ try:
+ await session_manager.save_session(session_id)
+ except Exception:
+ logger.debug("Failed to save session on disconnect", exc_info=True)
+
+ # Don't destroy immediately - mark as inactive for 15-min retention
+ await session_manager.mark_session_inactive(session_id)
+ cleanup_session_process_tracking()
+
+ try:
+ bus = get_message_bus()
+ bus.set_session_context(None)
+ except Exception:
+ pass
From c32a8cfd7b38e3a4e890f244920a431920b05c3f Mon Sep 17 00:00:00 2001
From: shravya maddipudi
Date: Thu, 28 May 2026 18:18:45 -0500
Subject: [PATCH 03/29] docs(migration): track ws/model-switch regressions and
fixes
---
docs/puppy_desk_migration_issues.md | 21 +++++++++++++++++++++
1 file changed, 21 insertions(+)
create mode 100644 docs/puppy_desk_migration_issues.md
diff --git a/docs/puppy_desk_migration_issues.md b/docs/puppy_desk_migration_issues.md
new file mode 100644
index 000000000..1ccf1a9c2
--- /dev/null
+++ b/docs/puppy_desk_migration_issues.md
@@ -0,0 +1,21 @@
+# Puppy Desk Migration - Active Issue List
+
+## Open / Tracked
+
+1. **Model switch failure**
+ `Failed to switch to model gpt-5.5: CodePuppyAgent object has no attribute set_session_model`
+ - Status: **Fixed in commit `772e52ed`**
+
+2. **Post-CWD-change chat failure**
+ `Agent run failed (no result returned)` after `set_working_directory`
+ - Root cause: raw dict injection into agent message history
+ - Status: **Fix committed in `5c205a85`**
+
+3. **Config schema endpoint crash**
+ `ImportError: cannot import name 'CONFIG_SCHEMA' from code_puppy.config` on `/config/schema`
+ - Status: **Fix committed in `5c205a85`**
+
+## Residual Migration Gaps
+
+- `code_puppy/api/` and related ws/runtime files are still largely untracked in this branch and need staged migration/normalization.
+- Additional end-to-end GUI validation is pending after full API migration commit set.
From 4e3fc17a76acde44004ad84b96d7dc22546eef43 Mon Sep 17 00:00:00 2001
From: shravya maddipudi
Date: Thu, 28 May 2026 18:38:17 -0500
Subject: [PATCH 04/29] feat(api): migrate core api, db, routers, and session
context
---
code_puppy/api/__init__.py | 13 +
code_puppy/api/app.py | 329 +++++++
code_puppy/api/compaction_tracker.py | 130 +++
code_puppy/api/db/__init__.py | 49 +
code_puppy/api/db/backfill.py | 381 ++++++++
code_puppy/api/db/connection.py | 426 +++++++++
code_puppy/api/db/message_utils.py | 219 +++++
code_puppy/api/db/queries.py | 1269 +++++++++++++++++++++++++
code_puppy/api/db/seeder.py | 555 +++++++++++
code_puppy/api/error_parser.py | 239 +++++
code_puppy/api/main.py | 97 ++
code_puppy/api/permission_plugin.py | 216 +++++
code_puppy/api/permissions.py | 172 ++++
code_puppy/api/pty_manager.py | 453 +++++++++
code_puppy/api/routers/__init__.py | 12 +
code_puppy/api/routers/agents.py | 116 +++
code_puppy/api/routers/commands.py | 217 +++++
code_puppy/api/routers/models.py | 23 +
code_puppy/api/routers/protocol.py | 26 +
code_puppy/api/routers/sessions.py | 274 ++++++
code_puppy/api/routers/ws_sessions.py | 302 ++++++
code_puppy/api/session_cache.py | 392 ++++++++
code_puppy/api/session_context.py | 675 +++++++++++++
code_puppy/api/tool_formatters.py | 138 +++
code_puppy/api/websocket.py | 51 +
25 files changed, 6774 insertions(+)
create mode 100644 code_puppy/api/__init__.py
create mode 100644 code_puppy/api/app.py
create mode 100644 code_puppy/api/compaction_tracker.py
create mode 100644 code_puppy/api/db/__init__.py
create mode 100644 code_puppy/api/db/backfill.py
create mode 100644 code_puppy/api/db/connection.py
create mode 100644 code_puppy/api/db/message_utils.py
create mode 100644 code_puppy/api/db/queries.py
create mode 100644 code_puppy/api/db/seeder.py
create mode 100644 code_puppy/api/error_parser.py
create mode 100644 code_puppy/api/main.py
create mode 100644 code_puppy/api/permission_plugin.py
create mode 100644 code_puppy/api/permissions.py
create mode 100644 code_puppy/api/pty_manager.py
create mode 100644 code_puppy/api/routers/__init__.py
create mode 100644 code_puppy/api/routers/agents.py
create mode 100644 code_puppy/api/routers/commands.py
create mode 100644 code_puppy/api/routers/models.py
create mode 100644 code_puppy/api/routers/protocol.py
create mode 100644 code_puppy/api/routers/sessions.py
create mode 100644 code_puppy/api/routers/ws_sessions.py
create mode 100644 code_puppy/api/session_cache.py
create mode 100644 code_puppy/api/session_context.py
create mode 100644 code_puppy/api/tool_formatters.py
create mode 100644 code_puppy/api/websocket.py
diff --git a/code_puppy/api/__init__.py b/code_puppy/api/__init__.py
new file mode 100644
index 000000000..9e503eeb1
--- /dev/null
+++ b/code_puppy/api/__init__.py
@@ -0,0 +1,13 @@
+"""Code Puppy REST API module.
+
+This module provides a FastAPI-based REST API for Code Puppy configuration,
+sessions, commands, and real-time WebSocket communication.
+
+Exports:
+ create_app: Factory function to create the FastAPI application
+ main: Entry point to run the server
+"""
+
+from code_puppy.api.app import create_app
+
+__all__ = ["create_app"]
diff --git a/code_puppy/api/app.py b/code_puppy/api/app.py
new file mode 100644
index 000000000..a68b86c14
--- /dev/null
+++ b/code_puppy/api/app.py
@@ -0,0 +1,329 @@
+"""FastAPI application factory for Code Puppy API."""
+
+import asyncio
+import logging
+import os
+import time
+from contextlib import asynccontextmanager
+from pathlib import Path
+from typing import AsyncGenerator
+
+from dbos import DBOS, DBOSConfig
+from fastapi import FastAPI, Request
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
+from starlette.middleware.base import BaseHTTPMiddleware
+
+from code_puppy.config import DBOS_DATABASE_URL, get_use_dbos
+
+logger = logging.getLogger(__name__)
+
+# Default request timeout (seconds) - fail fast!
+REQUEST_TIMEOUT = 30.0
+
+
+class TimeoutMiddleware(BaseHTTPMiddleware):
+ """Middleware to enforce request timeouts and prevent hanging requests."""
+
+ def __init__(self, app, timeout: float = REQUEST_TIMEOUT):
+ super().__init__(app)
+ self.timeout = timeout
+
+ async def dispatch(self, request: Request, call_next):
+ # Skip timeout for WebSocket upgrades and streaming endpoints
+ if request.headers.get(
+ "upgrade", ""
+ ).lower() == "websocket" or request.url.path.startswith("/ws/"):
+ return await call_next(request)
+
+ try:
+ return await asyncio.wait_for(
+ call_next(request),
+ timeout=self.timeout,
+ )
+ except asyncio.TimeoutError:
+ return JSONResponse(
+ status_code=504,
+ content={
+ "detail": f"Request timed out after {self.timeout}s",
+ "error": "timeout",
+ },
+ )
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
+ """Lifespan context manager for startup and shutdown events.
+
+ Handles graceful cleanup of resources when the server shuts down.
+ """
+ # Startup
+ logger.info("🐶 Code Puppy API starting up...")
+
+ # Load plugin callbacks (including frontend_emitter for real-time streaming)
+ from code_puppy import plugins
+
+ result = plugins.load_plugin_callbacks()
+ logger.info(
+ f"✓ Loaded plugins: builtin={result['builtin']}, user={result['user']}, external={result['external']}"
+ )
+
+ # Initialise shared SQLite database
+ try:
+ from code_puppy.api.db.connection import init_db
+ from code_puppy.api.db.seeder import seed_from_pkl_dirs
+
+ await init_db()
+ logger.info("✓ aiosqlite DB initialised")
+
+ # Seed existing pkl sessions in the background — non-blocking
+ asyncio.create_task(seed_from_pkl_dirs())
+ logger.info("✓ SQLite seeder task started")
+ except Exception as _db_exc:
+ logger.error("SQLite DB init failed (continuing without DB): %s", _db_exc)
+
+ # Initialize DBOS if enabled (mirrors cli_runner.py; API server logs errors and continues degraded rather than exiting)
+ _dbos_initialized = [False] # use list to allow mutation across yield boundary
+ if get_use_dbos():
+ from code_puppy import __version__ as current_version
+
+ dbos_app_version = os.environ.get(
+ "DBOS_APP_VERSION", f"{current_version}-{int(time.time() * 1000)}"
+ )
+ dbos_config: DBOSConfig = {
+ "name": "dbos-code-puppy",
+ "system_database_url": DBOS_DATABASE_URL,
+ "run_admin_server": False,
+ "conductor_key": os.environ.get("DBOS_CONDUCTOR_KEY"),
+ "log_level": os.environ.get("DBOS_LOG_LEVEL", "ERROR"),
+ "application_version": dbos_app_version,
+ }
+ try:
+ DBOS(config=dbos_config)
+ DBOS.launch()
+ _dbos_initialized[0] = True # only set after both calls succeed
+ logger.info("✓ DBOS initialized")
+ except Exception as e:
+ logger.error("Error initializing DBOS: %s", e)
+
+ yield
+ # Shutdown: clean up all the things!
+ logger.info("🐶 Code Puppy API shutting down, cleaning up...")
+
+ # 1. Close all PTY sessions
+ try:
+ from code_puppy.api.pty_manager import get_pty_manager
+
+ pty_manager = get_pty_manager()
+ await pty_manager.close_all()
+ logger.info("✓ All PTY sessions closed")
+ except Exception as e:
+ logger.error("Error closing PTY sessions: %s", e)
+
+ # 2. Shutdown session cache thread pool executor
+ try:
+ from code_puppy.api.session_cache import shutdown_executor
+
+ await shutdown_executor()
+ logger.info("✓ Session cache executor shut down")
+ except Exception as e:
+ logger.error("Error shutting down session cache executor: %s", e)
+
+ # 3. Shutdown ws_sessions thread pool executor
+ try:
+ from code_puppy.api.routers import ws_sessions
+
+ ws_sessions._executor.shutdown(wait=False)
+ logger.info("✓ WS sessions executor shut down")
+ except Exception as e:
+ logger.error("Error shutting down ws_sessions executor: %s", e)
+
+ # 4. Remove PID file so /api status knows we're gone
+ try:
+ from code_puppy.config import STATE_DIR
+
+ pid_file = Path(STATE_DIR) / "api_server.pid"
+ if pid_file.exists():
+ pid_file.unlink()
+ logger.info("✓ PID file removed")
+ except Exception as e:
+ logger.error("Error removing PID file: %s", e)
+
+ # Destroy DBOS if it was fully initialized
+ if _dbos_initialized[0]:
+ try:
+ DBOS.destroy()
+ logger.info("✓ DBOS destroyed")
+ except Exception as e:
+ logger.error("Error destroying DBOS: %s", e)
+
+ # 6. Close SQLite database
+ try:
+ from code_puppy.api.db.connection import close_db
+
+ await close_db()
+ logger.info("✓ aiosqlite DB closed")
+ except Exception as e:
+ logger.error("Error closing SQLite DB: %s", e)
+
+
+def create_app() -> FastAPI:
+ """Create and configure the FastAPI application."""
+ app = FastAPI(
+ lifespan=lifespan,
+ title="Code Puppy API",
+ description="REST API and Interactive Terminal for Code Puppy",
+ version="1.0.0",
+ docs_url="/docs",
+ redoc_url="/redoc",
+ )
+
+ # Timeout middleware - added first so it wraps everything
+ app.add_middleware(TimeoutMiddleware, timeout=REQUEST_TIMEOUT)
+
+ # CORS middleware for frontend access
+ app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"], # Local/trusted
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+ )
+
+ # Include routers
+ from code_puppy.api.routers import (
+ agents,
+ commands,
+ config,
+ sessions,
+ ws_sessions,
+ )
+
+ app.include_router(config.router, prefix="/api/config", tags=["config"])
+ app.include_router(commands.router, prefix="/api/commands", tags=["commands"])
+ app.include_router(sessions.router, prefix="/api/sessions", tags=["sessions"])
+ app.include_router(
+ ws_sessions.router, prefix="/api/ws-sessions", tags=["ws-sessions"]
+ )
+ app.include_router(agents.router, prefix="/api/agents", tags=["agents"])
+
+ from code_puppy.api.routers import models
+
+ app.include_router(models.router, prefix="/api/models", tags=["models"])
+
+ from code_puppy.api.routers import protocol
+
+ app.include_router(protocol.router, prefix="/api/protocol", tags=["protocol"])
+
+ # WebSocket endpoints (events + terminal)
+ from code_puppy.api.websocket import setup_websocket
+
+ setup_websocket(app)
+
+ # Templates directory
+ templates_dir = Path(__file__).parent / "templates"
+
+ @app.get("/")
+ async def root():
+ """Landing page with links to terminal and docs."""
+ return HTMLResponse(
+ content="""
+
+
+
+ Code Puppy 🐶
+
+
+
+
+
🐶
+
Code Puppy
+
+
+ WebSocket: ws://localhost:8765/ws/terminal
+
+
+
+
+ """
+ )
+
+ @app.get("/terminal")
+ async def terminal_page():
+ """Serve the interactive terminal page."""
+ html_file = templates_dir / "terminal.html"
+ if html_file.exists():
+ return FileResponse(html_file, media_type="text/html")
+ return HTMLResponse(
+ content="Terminal template not found
",
+ status_code=404,
+ )
+
+ @app.get("/sessions")
+ async def sessions_page():
+ """Serve the sessions monitoring page."""
+ html_file = templates_dir / "sessions.html"
+ if html_file.exists():
+ return FileResponse(html_file, media_type="text/html")
+ return HTMLResponse(
+ content="Sessions template not found
",
+ status_code=404,
+ )
+
+ @app.get("/chat")
+ async def chat_page():
+ """Serve the chat interface page."""
+ html_file = templates_dir / "chat.html"
+ if html_file.exists():
+ return FileResponse(html_file, media_type="text/html")
+ return HTMLResponse(
+ content="Chat template not found
",
+ status_code=404,
+ )
+
+ @app.get("/health")
+ async def health():
+ """Simple health check endpoint."""
+ return {"status": "healthy"}
+
+ @app.get("/api/version-check")
+ async def version_check():
+ """
+ Check current version and latest available version.
+
+ Returns:
+ dict: Contains current_version, latest_version, and update_available
+ """
+ from code_puppy import __version__
+ from code_puppy.plugins.walmart_specific.auto_update import fetch_latest_version
+ from code_puppy.version_checker import versions_are_equal
+
+ current_version = __version__
+ latest_version = fetch_latest_version()
+
+ # Determine if update is available
+ update_available = False
+ if latest_version:
+ update_available = not versions_are_equal(current_version, latest_version)
+
+ return {
+ "current_version": current_version,
+ "latest_version": latest_version or current_version,
+ "update_available": update_available,
+ "status": "success" if latest_version else "error_fetching_latest",
+ }
+
+ return app
diff --git a/code_puppy/api/compaction_tracker.py b/code_puppy/api/compaction_tracker.py
new file mode 100644
index 000000000..8145b39c9
--- /dev/null
+++ b/code_puppy/api/compaction_tracker.py
@@ -0,0 +1,130 @@
+"""Compaction tracking — writes compaction metadata to SQLite."""
+
+import logging
+from datetime import datetime, timezone
+from typing import Any, List
+
+logger = logging.getLogger(__name__)
+
+
+def track_compaction_event(
+ session_id: str,
+ messages_before: List[Any],
+ messages_after: List[Any],
+ strategy: str,
+ agent: Any | None = None,
+) -> None:
+ """
+ Track a compaction event in SQLite.
+
+ Called directly from message_history_processor after compaction completes.
+
+ Args:
+ session_id: Session identifier
+ messages_before: Message history before compaction
+ messages_after: Message history after compaction
+ strategy: 'summarization' or 'truncation'
+ agent: Agent instance (optional, used for token estimation)
+ """
+ # Only proceed if compaction actually happened (message count decreased)
+ if len(messages_after) >= len(messages_before):
+ logger.debug(
+ "Compaction tracker: no size reduction (%d -> %d), skipping",
+ len(messages_before),
+ len(messages_after),
+ )
+ return
+
+ try:
+ from code_puppy.api.db.connection import get_db, get_write_lock
+
+ # Estimate tokens (use agent's method if available, else default)
+ def fallback_estimate(m: Any) -> int:
+ return 100
+
+ if agent and hasattr(agent, "estimate_tokens_for_message"):
+ estimate_tokens = agent.estimate_tokens_for_message
+ else:
+ estimate_tokens = fallback_estimate
+
+ # Calculate compaction metadata
+ compacted_count = len(messages_before) - len(messages_after)
+
+ # Estimate tokens for source messages (messages that were compacted)
+ # Assume first message is system prompt, compaction starts at index 1
+ source_messages = messages_before[1 : compacted_count + 1]
+ source_tokens = sum(estimate_tokens(m) for m in source_messages)
+
+ # Extract summary text from new messages (compare object IDs)
+ before_ids = {id(m) for m in messages_before}
+ new_messages = [m for m in messages_after if id(m) not in before_ids]
+
+ summary_text = "(no summary found)"
+ summary_tokens = 0
+ if new_messages:
+ summary_msg = new_messages[0]
+ text_parts = []
+ for part in getattr(summary_msg, "parts", []) or []:
+ if hasattr(part, "content"):
+ text_parts.append(str(part.content))
+ summary_text = " ".join(text_parts) if text_parts else "(empty summary)"
+ summary_tokens = estimate_tokens(summary_msg)
+
+ # Assume compaction removes from the start (after system message at seq 1)
+ source_start = 2 # First message after system prompt
+ source_end = source_start + compacted_count - 1
+
+ # Create compaction_log row
+ created_at = datetime.now(timezone.utc).isoformat()
+
+ with get_write_lock():
+ db = get_db()
+ cursor = db.execute(
+ """
+ INSERT INTO compaction_log
+ (session_id, summary_text, source_start, source_end,
+ source_count, source_tokens, summary_tokens, strategy, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ session_id,
+ summary_text[:1000], # Truncate very long summaries
+ source_start,
+ source_end,
+ compacted_count,
+ source_tokens,
+ summary_tokens,
+ strategy,
+ created_at,
+ ),
+ )
+ compaction_log_id = cursor.lastrowid
+
+ # Mark compacted messages in the messages table
+ rows_updated = db.execute(
+ """
+ UPDATE messages
+ SET compacted = 1, compaction_log_id = ?
+ WHERE session_id = ? AND seq >= ? AND seq <= ? AND compacted = 0
+ """,
+ (compaction_log_id, session_id, source_start, source_end),
+ ).rowcount
+
+ db.commit()
+
+ logger.info(
+ "Compaction tracked: session=%s log_id=%d seq=%d-%d count=%d marked=%d strategy=%s",
+ session_id,
+ compaction_log_id,
+ source_start,
+ source_end,
+ compacted_count,
+ rows_updated,
+ strategy,
+ )
+
+ except Exception as e:
+ # Non-fatal — compaction still worked, we just didn't track it
+ logger.warning(
+ "Compaction tracker failed for %s: %s", session_id, e, exc_info=True
+ )
diff --git a/code_puppy/api/db/__init__.py b/code_puppy/api/db/__init__.py
new file mode 100644
index 000000000..f7805b048
--- /dev/null
+++ b/code_puppy/api/db/__init__.py
@@ -0,0 +1,49 @@
+"""SQLite persistence layer for the Code Puppy WS API.
+
+Stores session history in ~/.puppy_desk/chat_messages.db — the same
+database that the Desk Puppy Electron FE reads from.
+
+All database operations are async coroutines backed by aiosqlite.
+"""
+
+from code_puppy.api.db.connection import close_db, get_db, init_db
+from code_puppy.api.db.queries import (
+ get_active_messages,
+ get_next_seq,
+ get_session_metadata,
+ get_session_row,
+ insert_compaction_log,
+ insert_message,
+ insert_tool_call,
+ mark_messages_compacted,
+ session_exists,
+ soft_delete_session,
+ update_session_stats,
+ update_session_working_directory,
+ upsert_session,
+ write_system_message_to_sqlite,
+ write_turn_to_sqlite,
+)
+from code_puppy.api.db.seeder import seed_from_pkl_dirs
+
+__all__ = [
+ "init_db",
+ "close_db",
+ "get_db",
+ "get_session_row",
+ "get_session_metadata",
+ "session_exists",
+ "upsert_session",
+ "soft_delete_session",
+ "update_session_stats",
+ "update_session_working_directory",
+ "write_system_message_to_sqlite",
+ "get_next_seq",
+ "insert_message",
+ "insert_tool_call",
+ "get_active_messages",
+ "mark_messages_compacted",
+ "insert_compaction_log",
+ "seed_from_pkl_dirs",
+ "write_turn_to_sqlite",
+]
diff --git a/code_puppy/api/db/backfill.py b/code_puppy/api/db/backfill.py
new file mode 100644
index 000000000..142c0f471
--- /dev/null
+++ b/code_puppy/api/db/backfill.py
@@ -0,0 +1,381 @@
+"""Backfill helpers for the messages table.
+
+Currently contains one migration helper:
+
+ backfill_pydantic_json(db)
+ Reconstructs pydantic_json from the plain `content` column for any
+ message row where pydantic_json IS NULL. Used by the v4→v5 schema
+ migration in connection.py.
+
+Design notes
+------------
+* Only 'user' and 'assistant' roles are processed — 'system' rows have no
+ pydantic_json and the load path already skips them.
+* The reconstruction is lossy for assistant messages that originally contained
+ tool calls or extended thinking, because that structure was never stored in
+ the `content` column. The backfill preserves the visible text so sessions
+ resume with *something* rather than blank history.
+* The function is idempotent: it filters on ``pydantic_json IS NULL`` so
+ running it twice is safe.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+ import aiosqlite
+
+logger = logging.getLogger(__name__)
+
+
+def _build_message(role: str, content: str, model_name: str) -> Any:
+ """Build a pydantic-ai ModelMessage from a plain content string.
+
+ Args:
+ role: 'user' or 'assistant'.
+ content: Plain text content extracted from the messages row.
+ model_name: Model name stored on the row (used for ModelResponse).
+
+ Returns:
+ A ModelRequest or ModelResponse instance ready for serialisation.
+
+ Raises:
+ ValueError: If *role* is not 'user' or 'assistant'.
+ """
+ from pydantic_ai.messages import (
+ ModelRequest,
+ ModelResponse,
+ TextPart,
+ UserPromptPart,
+ )
+
+ if role == "user":
+ return ModelRequest(parts=[UserPromptPart(content=content)])
+ if role == "assistant":
+ return ModelResponse(
+ parts=[TextPart(content=content)],
+ model_name=model_name or "unknown",
+ )
+ raise ValueError(f"Unsupported role for backfill: {role!r}")
+
+
+def _serialize(msg: Any) -> str:
+ """Serialise a single ModelMessage to the pydantic_json wire format.
+
+ Stores as a single-element JSON array — identical to what
+ pydantic_json_for_message() produces during normal chat saves.
+ """
+ from pydantic_ai.messages import ModelMessagesTypeAdapter
+
+ return ModelMessagesTypeAdapter.dump_json([msg]).decode("utf-8")
+
+
+async def backfill_pydantic_json(
+ db: "aiosqlite.Connection",
+ *,
+ batch_size: int = 200,
+) -> tuple[int, int]:
+ """Backfill pydantic_json for message rows where it is currently NULL.
+
+ Reads all eligible rows in one query, groups UPDATE statements into
+ batches of *batch_size* to keep transactions small, and commits after
+ each batch so a mid-run crash leaves the DB consistent.
+
+ Args:
+ db: Open aiosqlite connection (must have row_factory set).
+ batch_size: Number of rows updated per transaction.
+
+ Returns:
+ ``(updated, skipped)`` — row counts for rows successfully backfilled
+ and rows skipped due to serialisation errors.
+ """
+ cursor = await db.execute(
+ """
+ SELECT id, session_id, seq, role, content, model_name
+ FROM messages
+ WHERE pydantic_json IS NULL
+ AND compacted = 0
+ AND role IN ('user', 'assistant')
+ ORDER BY session_id, seq
+ """
+ )
+ rows = await cursor.fetchall()
+
+ if not rows:
+ logger.info("backfill_pydantic_json: no NULL rows found — nothing to do")
+ return 0, 0
+
+ logger.info("backfill_pydantic_json: %d rows to backfill", len(rows))
+
+ updated = 0
+ skipped = 0
+ batch: list[tuple[str, int]] = [] # (pydantic_json, id)
+
+ for row in rows:
+ row_id: int = row["id"]
+ role: str = row["role"]
+ content: str = row["content"] or ""
+ model_name: str = row["model_name"] or "unknown"
+ session_id: str = row["session_id"]
+ seq: int = row["seq"]
+
+ try:
+ msg = _build_message(role, content, model_name)
+ pj = _serialize(msg)
+ batch.append((pj, row_id))
+ except Exception as exc:
+ logger.warning(
+ "backfill_pydantic_json: skipping id=%s (session=%s seq=%s role=%s): %s",
+ row_id,
+ session_id,
+ seq,
+ role,
+ exc,
+ )
+ skipped += 1
+
+ if len(batch) >= batch_size:
+ await _flush_batch(db, batch)
+ updated += len(batch)
+ batch = []
+
+ if batch:
+ await _flush_batch(db, batch)
+ updated += len(batch)
+
+ logger.info(
+ "backfill_pydantic_json: done — %d updated, %d skipped",
+ updated,
+ skipped,
+ )
+ return updated, skipped
+
+
+async def _flush_batch(
+ db: "aiosqlite.Connection",
+ batch: list[tuple[str, int]],
+) -> None:
+ """Write one batch of (pydantic_json, id) pairs and commit."""
+ await db.executemany(
+ "UPDATE messages SET pydantic_json = ? WHERE id = ?",
+ batch,
+ )
+ await db.commit()
+
+
+# ---------------------------------------------------------------------------
+# Tool-call backfill (added to fix the early-continue bug in
+# write_turn_to_sqlite that prevented tool_calls rows from ever being written)
+# ---------------------------------------------------------------------------
+
+
+async def backfill_tool_calls(
+ db: "aiosqlite.Connection",
+ *,
+ session_id: str | None = None,
+ batch_size: int = 100,
+) -> tuple[int, int]:
+ """Backfill tool_call rows from pydantic_json for sessions saved without them.
+
+ Sessions written before the fix for the early-continue bug in
+ ``write_turn_to_sqlite`` have no rows in ``tool_calls`` even though the
+ pydantic_json column contains full ToolCallPart / ToolReturnPart data.
+
+ This function replays each session's message history in seq order,
+ matches ToolCallPart entries in assistant messages with ToolReturnPart
+ entries in the following user messages, and inserts the reconstructed
+ rows into ``tool_calls``. Uses ``INSERT OR IGNORE`` — fully idempotent.
+
+ Args:
+ db: Open aiosqlite connection with ``row_factory`` set to
+ ``aiosqlite.Row``.
+ session_id: If provided, only process that one session; otherwise
+ process every session that has pydantic_json rows.
+ batch_size: Number of ``tool_calls`` rows inserted per commit.
+
+ Returns:
+ ``(inserted, skipped)`` row counts.
+ """
+ import json
+ import time
+ import uuid
+
+ from pydantic_ai.messages import ModelMessagesTypeAdapter
+
+ where_clause = "WHERE m.pydantic_json IS NOT NULL AND m.compacted = 0"
+ params: tuple = ()
+ if session_id:
+ where_clause += " AND m.session_id = ?"
+ params = (session_id,)
+
+ cursor = await db.execute(
+ f"""
+ SELECT m.id, m.session_id, m.seq, m.role,
+ m.agent_name, m.model_name, m.timestamp, m.pydantic_json
+ FROM messages m
+ {where_clause}
+ ORDER BY m.session_id, m.seq
+ """,
+ params,
+ )
+ rows = await cursor.fetchall()
+
+ if not rows:
+ logger.info("backfill_tool_calls: no eligible messages found — nothing to do")
+ return 0, 0
+
+ logger.info(
+ "backfill_tool_calls: processing %d message rows%s",
+ len(rows),
+ f" for session={session_id}" if session_id else "",
+ )
+
+ inserted = 0
+ skipped = 0
+ batch: list[tuple] = []
+
+ # Per-session state: pending tool calls keyed by tool_call_id
+ current_session: str | None = None
+ # tool_call_id → {name, args, parent_seq, agent, model, ts}
+ pending: dict[str, dict] = {}
+
+ for row in rows:
+ row_session = row["session_id"]
+ if row_session != current_session:
+ # Reset pending state when we enter a new session
+ current_session = row_session
+ pending = {}
+
+ try:
+ parsed = ModelMessagesTypeAdapter.validate_json(row["pydantic_json"])
+ except Exception as exc:
+ logger.warning(
+ "backfill_tool_calls: skip seq=%s session=%s parse error: %s",
+ row["seq"],
+ row["session_id"],
+ exc,
+ )
+ skipped += 1
+ continue
+
+ if not parsed:
+ continue
+
+ msg = parsed[0]
+ role: str = row["role"]
+ seq: int = row["seq"]
+ agent_name: str = row["agent_name"] or ""
+ model_name: str = row["model_name"] or ""
+
+ # Collect ToolCallParts from assistant (ModelResponse) messages
+ if role == "assistant":
+ for part in getattr(msg, "parts", []):
+ if type(part).__name__ != "ToolCallPart":
+ continue
+ tc_id = getattr(part, "tool_call_id", "") or str(uuid.uuid4())
+ args = getattr(part, "args", {})
+ if hasattr(args, "model_dump"):
+ args = args.model_dump()
+ elif hasattr(args, "__dict__"):
+ args = dict(args.__dict__)
+ pending[tc_id] = {
+ "name": getattr(part, "tool_name", "unknown"),
+ "args": args,
+ "parent_seq": seq,
+ "agent": agent_name,
+ "model": model_name,
+ "ts": row["timestamp"],
+ }
+
+ # Match ToolReturnParts from user (ModelRequest) messages
+ elif role == "user":
+ for part in getattr(msg, "parts", []):
+ if type(part).__name__ != "ToolReturnPart":
+ continue
+ tc_id = getattr(part, "tool_call_id", "")
+ if not tc_id or tc_id not in pending:
+ continue
+
+ tc = pending.pop(tc_id)
+ content = getattr(part, "content", "")
+ try:
+ result_val = (
+ json.loads(content) if isinstance(content, str) else content
+ )
+ except Exception:
+ result_val = content
+
+ try:
+ args_json_str = json.dumps(tc["args"])
+ except Exception:
+ args_json_str = str(tc["args"])
+ try:
+ result_json_str = json.dumps(result_val)
+ except Exception:
+ result_json_str = str(result_val)
+
+ try:
+ from datetime import datetime as _dt
+
+ tool_ts = _dt.fromisoformat(
+ str(tc["ts"]).replace("Z", "+00:00")
+ ).timestamp()
+ except Exception:
+ tool_ts = time.time()
+
+ batch.append(
+ (
+ tc_id, # id
+ row_session, # session_id
+ tc["parent_seq"], # parent_message_seq
+ tc[
+ "parent_seq"
+ ], # seq (best approximation without original seq)
+ tc["name"], # tool_name
+ args_json_str, # args_json
+ result_json_str, # result_json
+ "success", # status
+ None, # duration_ms
+ None, # error_text
+ tc["agent"], # agent_name
+ tc["model"], # model_name
+ tool_ts, # timestamp
+ )
+ )
+
+ if len(batch) >= batch_size:
+ n = await _flush_tool_call_batch(db, batch)
+ inserted += n
+ batch = []
+
+ if batch:
+ n = await _flush_tool_call_batch(db, batch)
+ inserted += n
+
+ logger.info(
+ "backfill_tool_calls: done — %d inserted, %d skipped",
+ inserted,
+ skipped,
+ )
+ return inserted, skipped
+
+
+async def _flush_tool_call_batch(
+ db: "aiosqlite.Connection",
+ batch: list[tuple],
+) -> int:
+ """INSERT OR IGNORE a batch of tool_call rows and commit."""
+ await db.executemany(
+ """
+ INSERT OR IGNORE INTO tool_calls
+ (id, session_id, parent_message_seq, seq, tool_name,
+ args_json, result_json, status, duration_ms, error_text,
+ agent_name, model_name, timestamp)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ batch,
+ )
+ await db.commit()
+ return len(batch)
diff --git a/code_puppy/api/db/connection.py b/code_puppy/api/db/connection.py
new file mode 100644
index 000000000..d60f9e2b2
--- /dev/null
+++ b/code_puppy/api/db/connection.py
@@ -0,0 +1,426 @@
+"""SQLite connection singleton using aiosqlite for non-blocking async access.
+
+The Desk Puppy Electron FE owns the schema (creates it at user_version=2).
+This module opens the same database from the Python BE, runs any needed
+migrations if the FE hasn't run yet, and exposes get_db() / init_db() /
+close_db() for use by queries.py and the seeder.
+
+Schema versions:
+ 0 = no schema yet (fresh DB)
+ 1 = added token_count, compacted, pydantic_json, compaction_log_id to messages
+ 2 = added deleted_at to sessions (soft delete)
+ 3 = rebuilt messages table with UNIQUE(session_id, seq) + dedup
+ 4 = backfill blank agent_name / model_name in messages + tool_calls from session defaults
+
+Concurrency:
+ aiosqlite serialises all DB operations through its own internal background
+ thread — no threading.Lock required. All callers simply await the relevant
+ coroutine; the event loop is never blocked.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+from pathlib import Path
+from typing import Optional
+
+import aiosqlite
+
+logger = logging.getLogger(__name__)
+
+# ---------------------------------------------------------------------------
+# Path resolution
+# ---------------------------------------------------------------------------
+
+_PUPPY_DESK_DB_ENV = "PUPPY_DESK_DB"
+
+
+def get_db_path() -> Path:
+ """Return path to the shared SQLite database.
+
+ Override with PUPPY_DESK_DB env var for testing.
+ """
+ env = os.environ.get(_PUPPY_DESK_DB_ENV)
+ if env:
+ return Path(env)
+ return Path.home() / ".puppy_desk" / "chat_messages.db"
+
+
+# ---------------------------------------------------------------------------
+# Full schema (v4) — created on first BE run if FE hasn't seeded it yet
+# ---------------------------------------------------------------------------
+
+_SCHEMA_SQL = """
+CREATE TABLE IF NOT EXISTS sessions (
+ session_id TEXT PRIMARY KEY,
+ title TEXT DEFAULT '',
+ agent_name TEXT DEFAULT 'code-puppy',
+ model_name TEXT DEFAULT '',
+ working_directory TEXT DEFAULT '',
+ pinned INTEGER DEFAULT 0,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ message_count INTEGER DEFAULT 0,
+ total_tokens INTEGER DEFAULT 0,
+ deleted_at TEXT
+);
+CREATE INDEX IF NOT EXISTS idx_sessions_updated ON sessions(updated_at DESC);
+CREATE INDEX IF NOT EXISTS idx_sessions_deleted ON sessions(deleted_at);
+
+CREATE TABLE IF NOT EXISTS compaction_log (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ session_id TEXT NOT NULL,
+ summary_text TEXT NOT NULL,
+ source_start INTEGER NOT NULL,
+ source_end INTEGER NOT NULL,
+ source_count INTEGER NOT NULL,
+ source_tokens INTEGER NOT NULL,
+ summary_tokens INTEGER NOT NULL,
+ strategy TEXT DEFAULT 'summarization',
+ created_at TEXT NOT NULL,
+ FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
+);
+CREATE INDEX IF NOT EXISTS idx_compaction_session ON compaction_log(session_id);
+
+CREATE TABLE IF NOT EXISTS messages (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ session_id TEXT NOT NULL,
+ seq INTEGER NOT NULL,
+ role TEXT NOT NULL,
+ content TEXT DEFAULT '',
+ type TEXT DEFAULT '',
+ agent_name TEXT DEFAULT '',
+ model_name TEXT DEFAULT '',
+ timestamp TEXT NOT NULL,
+ thinking TEXT,
+ attachments_json TEXT,
+ clean_content TEXT,
+ system_message_type TEXT,
+ system_message_path TEXT,
+ token_count INTEGER DEFAULT 0,
+ compacted INTEGER DEFAULT 0,
+ pydantic_json TEXT,
+ compaction_log_id INTEGER REFERENCES compaction_log(id) ON DELETE SET NULL,
+ FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE,
+ UNIQUE(session_id, seq)
+);
+CREATE INDEX IF NOT EXISTS idx_messages_session_seq ON messages(session_id, seq);
+CREATE INDEX IF NOT EXISTS idx_messages_active ON messages(session_id, compacted);
+
+CREATE TABLE IF NOT EXISTS tool_calls (
+ id TEXT PRIMARY KEY,
+ session_id TEXT NOT NULL,
+ parent_message_seq INTEGER,
+ seq INTEGER NOT NULL,
+ tool_name TEXT NOT NULL,
+ args_json TEXT,
+ result_json TEXT,
+ status TEXT DEFAULT 'running',
+ duration_ms INTEGER,
+ error_text TEXT,
+ agent_name TEXT DEFAULT '',
+ model_name TEXT DEFAULT '',
+ timestamp REAL NOT NULL,
+ FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
+);
+CREATE INDEX IF NOT EXISTS idx_tool_calls_session_seq ON tool_calls(session_id, seq);
+CREATE INDEX IF NOT EXISTS idx_tool_calls_parent ON tool_calls(parent_message_seq);
+"""
+
+# Migrations applied to existing databases that are below SCHEMA_VERSION
+_MIGRATION_V0_TO_V1: list[str] = [
+ "ALTER TABLE messages ADD COLUMN token_count INTEGER DEFAULT 0",
+ "ALTER TABLE messages ADD COLUMN compacted INTEGER DEFAULT 0",
+ "ALTER TABLE messages ADD COLUMN pydantic_json TEXT",
+ "ALTER TABLE messages ADD COLUMN compaction_log_id INTEGER REFERENCES compaction_log(id) ON DELETE SET NULL",
+]
+
+_MIGRATION_V1_TO_V2: list[str] = [
+ "ALTER TABLE sessions ADD COLUMN deleted_at TEXT",
+]
+
+# v3: Rebuild messages table to add UNIQUE(session_id, seq).
+# SQLite cannot add UNIQUE constraints via ALTER TABLE, so we must rebuild.
+_MIGRATION_V2_TO_V3_SQL = """
+PRAGMA foreign_keys = OFF;
+BEGIN;
+
+CREATE TABLE messages_v3 (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ session_id TEXT NOT NULL,
+ seq INTEGER NOT NULL,
+ role TEXT NOT NULL,
+ content TEXT DEFAULT '',
+ type TEXT DEFAULT '',
+ agent_name TEXT DEFAULT '',
+ model_name TEXT DEFAULT '',
+ timestamp TEXT NOT NULL,
+ thinking TEXT,
+ attachments_json TEXT,
+ clean_content TEXT,
+ system_message_type TEXT,
+ system_message_path TEXT,
+ token_count INTEGER DEFAULT 0,
+ compacted INTEGER DEFAULT 0,
+ pydantic_json TEXT,
+ compaction_log_id INTEGER REFERENCES compaction_log(id) ON DELETE SET NULL,
+ FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE,
+ UNIQUE(session_id, seq)
+);
+
+INSERT INTO messages_v3
+SELECT id, session_id, seq, role, content, type, agent_name, model_name,
+ timestamp, thinking, attachments_json, clean_content,
+ system_message_type, system_message_path,
+ token_count, compacted, pydantic_json, compaction_log_id
+FROM messages AS m1
+WHERE id = (
+ SELECT id FROM messages AS m2
+ WHERE m2.session_id = m1.session_id AND m2.seq = m1.seq
+ ORDER BY
+ (m2.clean_content IS NOT NULL) DESC,
+ (m2.pydantic_json IS NOT NULL) DESC,
+ m2.id DESC
+ LIMIT 1
+);
+
+DROP TABLE messages;
+ALTER TABLE messages_v3 RENAME TO messages;
+
+CREATE INDEX IF NOT EXISTS idx_messages_session_seq ON messages(session_id, seq);
+CREATE INDEX IF NOT EXISTS idx_messages_active ON messages(session_id, compacted);
+
+COMMIT;
+PRAGMA foreign_keys = ON;
+""".strip()
+
+SCHEMA_VERSION = 5
+
+# ---------------------------------------------------------------------------
+# Async singleton (aiosqlite)
+# ---------------------------------------------------------------------------
+
+_aconn: Optional[aiosqlite.Connection] = None
+
+
+async def init_db() -> None:
+ """Open the database and ensure schema is at SCHEMA_VERSION.
+
+ Safe to call multiple times — subsequent calls are no-ops.
+ """
+ global _aconn
+
+ if _aconn is not None:
+ return # already initialised
+
+ db_path = get_db_path()
+ db_path.parent.mkdir(parents=True, exist_ok=True)
+
+ logger.info("Opening aiosqlite DB at %s", db_path)
+ _aconn = await aiosqlite.connect(str(db_path))
+ _aconn.row_factory = aiosqlite.Row
+
+ # Performance + safety pragmas
+ await _aconn.execute("PRAGMA journal_mode = WAL")
+ await _aconn.execute("PRAGMA foreign_keys = ON")
+ await _aconn.execute("PRAGMA busy_timeout = 5000")
+
+ cursor = await _aconn.execute("PRAGMA user_version")
+ row = await cursor.fetchone()
+ current_version: int = row[0]
+
+ if current_version == 0:
+ # Fresh database — create everything at the current schema version
+ await _aconn.executescript(_SCHEMA_SQL)
+ await _aconn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
+ await _aconn.commit()
+ logger.info(
+ "\u2713 aiosqlite DB created (schema v%d) at %s", SCHEMA_VERSION, db_path
+ )
+ else:
+ # Existing database — run incremental migrations
+ await _run_alters_and_migrate(current_version)
+
+
+async def _run_alters_and_migrate(current_version: int) -> None:
+ """Apply all pending schema migrations in order."""
+ assert _aconn is not None
+
+ if current_version < 1:
+ await _run_alters(_MIGRATION_V0_TO_V1)
+ await _aconn.execute("PRAGMA user_version = 1")
+ await _aconn.commit()
+ logger.info("\u2713 aiosqlite DB migrated v0 \u2192 v1")
+
+ if current_version < 2:
+ await _run_alters(_MIGRATION_V1_TO_V2)
+ # Also create compaction_log and its index if missing (v0 DBs)
+ await _aconn.execute("""
+ CREATE TABLE IF NOT EXISTS compaction_log (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ session_id TEXT NOT NULL,
+ summary_text TEXT NOT NULL,
+ source_start INTEGER NOT NULL,
+ source_end INTEGER NOT NULL,
+ source_count INTEGER NOT NULL,
+ source_tokens INTEGER NOT NULL,
+ summary_tokens INTEGER NOT NULL,
+ strategy TEXT DEFAULT 'summarization',
+ created_at TEXT NOT NULL,
+ FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
+ )
+ """)
+ await _aconn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_compaction_session ON compaction_log(session_id)"
+ )
+ await _aconn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_sessions_deleted ON sessions(deleted_at)"
+ )
+ await _aconn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_messages_active ON messages(session_id, compacted)"
+ )
+ await _aconn.execute("PRAGMA user_version = 2")
+ await _aconn.commit()
+ logger.info("\u2713 aiosqlite DB migrated to v2")
+
+ if current_version < 3:
+ logger.info(
+ "Running v2\u21923 migration: rebuilding messages table with UNIQUE(session_id, seq)..."
+ )
+ await _aconn.executescript(_MIGRATION_V2_TO_V3_SQL)
+ await _aconn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
+ await _aconn.commit()
+ cursor = await _aconn.execute("SELECT COUNT(*) FROM messages")
+ row = await cursor.fetchone()
+ count = row[0]
+ logger.info(
+ "\u2713 aiosqlite DB migrated to v%d (%d messages after dedup)",
+ SCHEMA_VERSION,
+ count,
+ )
+
+ if current_version < 4:
+ logger.info(
+ "Running v3\u2192v4 migration: backfilling blank agent_name/model_name \u2026"
+ )
+ await _aconn.executescript("""
+ -- messages: blank agent_name → session.agent_name
+ UPDATE messages
+ SET agent_name = (
+ SELECT s.agent_name
+ FROM sessions s
+ WHERE s.session_id = messages.session_id
+ )
+ WHERE (agent_name IS NULL OR agent_name = '')
+ AND (
+ SELECT COALESCE(s.agent_name, '')
+ FROM sessions s
+ WHERE s.session_id = messages.session_id
+ ) != '';
+
+ -- messages: blank model_name → session.model_name
+ UPDATE messages
+ SET model_name = (
+ SELECT s.model_name
+ FROM sessions s
+ WHERE s.session_id = messages.session_id
+ )
+ WHERE (model_name IS NULL OR model_name = '')
+ AND (
+ SELECT COALESCE(s.model_name, '')
+ FROM sessions s
+ WHERE s.session_id = messages.session_id
+ ) != '';
+
+ -- tool_calls: blank agent_name → session.agent_name
+ UPDATE tool_calls
+ SET agent_name = (
+ SELECT s.agent_name
+ FROM sessions s
+ WHERE s.session_id = tool_calls.session_id
+ )
+ WHERE (agent_name IS NULL OR agent_name = '')
+ AND (
+ SELECT COALESCE(s.agent_name, '')
+ FROM sessions s
+ WHERE s.session_id = tool_calls.session_id
+ ) != '';
+
+ -- tool_calls: blank model_name → session.model_name
+ UPDATE tool_calls
+ SET model_name = (
+ SELECT s.model_name
+ FROM sessions s
+ WHERE s.session_id = tool_calls.session_id
+ )
+ WHERE (model_name IS NULL OR model_name = '')
+ AND (
+ SELECT COALESCE(s.model_name, '')
+ FROM sessions s
+ WHERE s.session_id = tool_calls.session_id
+ ) != '';
+ """)
+ await _aconn.execute("PRAGMA user_version = 4")
+ await _aconn.commit()
+ cursor = await _aconn.execute(
+ "SELECT COUNT(*) FROM messages WHERE agent_name != '' AND model_name != ''"
+ )
+ row = await cursor.fetchone()
+ msg_count = row[0]
+ logger.info(
+ "\u2713 aiosqlite DB migrated to v4 (%d messages with agent+model)",
+ msg_count,
+ )
+
+ if current_version < 5:
+ logger.info(
+ "Running v4\u2192v5 migration: backfilling pydantic_json for NULL rows \u2026"
+ )
+ from code_puppy.api.db.backfill import backfill_pydantic_json
+
+ updated, skipped = await backfill_pydantic_json(_aconn)
+ await _aconn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
+ await _aconn.commit()
+ logger.info(
+ "\u2713 aiosqlite DB migrated to v5 "
+ "(%d pydantic_json rows backfilled, %d skipped)",
+ updated,
+ skipped,
+ )
+
+ logger.info("\u2713 aiosqlite DB ready (schema v%d)", SCHEMA_VERSION)
+
+
+async def _run_alters(statements: list[str]) -> None:
+ """Run ALTER TABLE statements, skipping 'duplicate column' errors gracefully."""
+ assert _aconn is not None
+ for sql in statements:
+ try:
+ await _aconn.execute(sql)
+ except Exception as exc:
+ if "duplicate column" in str(exc).lower():
+ logger.debug("Skipping already-applied migration: %s", sql)
+ else:
+ raise
+
+
+def get_db() -> aiosqlite.Connection:
+ """Return the open aiosqlite connection.
+
+ Raises RuntimeError if init_db() has not been awaited.
+ """
+ if _aconn is None:
+ raise RuntimeError(
+ "aiosqlite DB not initialised — call `await init_db()` first"
+ )
+ return _aconn
+
+
+async def close_db() -> None:
+ """Close the database connection. Called during app shutdown."""
+ global _aconn
+ if _aconn is not None:
+ await _aconn.close()
+ _aconn = None
+ logger.info("aiosqlite DB closed")
diff --git a/code_puppy/api/db/message_utils.py b/code_puppy/api/db/message_utils.py
new file mode 100644
index 000000000..5a22d69b7
--- /dev/null
+++ b/code_puppy/api/db/message_utils.py
@@ -0,0 +1,219 @@
+"""Shared helpers for extracting display fields from pydantic-ai ModelMessages.
+
+Used by seeder.py (pkl import) and session_context.py (live WS saves).
+Must not import from seeder.py or session_context.py to avoid circular imports.
+"""
+
+from __future__ import annotations
+
+import logging
+import warnings
+from typing import Any, Optional
+
+logger = logging.getLogger(__name__)
+
+_PART_TYPE_TEXT = {"TextPart", "UserPromptPart", "SystemPromptPart"}
+_PART_TYPE_THINKING = {"ThinkingPart"}
+
+
+def get_role(msg: Any) -> str:
+ """Map a ModelMessage to a DB role string."""
+ kind = getattr(msg, "kind", None)
+ if kind == "request":
+ return "user"
+ if kind == "response":
+ return "assistant"
+ return "system"
+
+
+def extract_content(msg: Any) -> str:
+ """Extract human-readable text from a ModelMessage's parts."""
+ parts = getattr(msg, "parts", [])
+ texts = []
+ for part in parts:
+ if type(part).__name__ in _PART_TYPE_TEXT:
+ c = getattr(part, "content", "")
+ if isinstance(c, str) and c.strip():
+ texts.append(c)
+ return "\n".join(texts)
+
+
+def extract_thinking(msg: Any) -> Optional[str]:
+ """Extract extended thinking text from a ModelResponse's parts."""
+ parts = getattr(msg, "parts", [])
+ texts = [
+ getattr(p, "content", "")
+ for p in parts
+ if type(p).__name__ in _PART_TYPE_THINKING
+ and isinstance(getattr(p, "content", ""), str)
+ ]
+ joined = "\n".join(t for t in texts if t)
+ return joined or None
+
+
+def get_message_timestamp(msg: Any) -> Optional[str]:
+ """Return the message's own timestamp as an ISO string, or None."""
+ ts = getattr(msg, "timestamp", None)
+ if ts is None:
+ return None
+ try:
+ return ts.isoformat()
+ except Exception:
+ return str(ts)
+
+
+def pydantic_json_for_message(msg: Any) -> Optional[str]:
+ """Serialise a single ModelMessage to a JSON string via ModelMessagesTypeAdapter.
+
+ Stores as a single-element list: `[msg_json]`.
+ Deserialise with: `ModelMessagesTypeAdapter.validate_json(s)[0]`
+
+ Returns None if serialisation fails (e.g. very old pkl message types).
+ """
+ try:
+ from pydantic_ai.messages import ModelMessagesTypeAdapter
+
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore")
+ return ModelMessagesTypeAdapter.dump_json([msg]).decode("utf-8")
+ except Exception as exc:
+ logger.debug("pydantic_json serialisation failed: %s", exc)
+ return None
+
+
+_PART_TYPE_TOOL_CALL = {"ToolCallPart"}
+# Extended set of tool return part type names for runtime compatibility
+# Different versions of pydantic-ai and streaming may use different class names
+_PART_TYPE_TOOL_RETURN = {"ToolReturnPart", "ToolReturn", "RetryPromptPart"}
+
+
+def extract_tool_calls(msg: Any) -> list[dict[str, Any]]:
+ """Extract ToolCallPart entries from a ModelResponse.
+
+ Returns list of dicts: {id, name, args_dict}
+ """
+ import uuid
+
+ parts = getattr(msg, "parts", [])
+ result = []
+ for part in parts:
+ if type(part).__name__ not in _PART_TYPE_TOOL_CALL:
+ continue
+ args = getattr(part, "args", {})
+ if hasattr(args, "model_dump"):
+ args = args.model_dump()
+ elif hasattr(args, "__dict__"):
+ args = dict(args.__dict__)
+ result.append(
+ {
+ "id": getattr(part, "tool_call_id", "") or str(uuid.uuid4()),
+ "name": getattr(part, "tool_name", "unknown"),
+ "args": args,
+ }
+ )
+ return result
+
+
+def _is_tool_return_like(part: Any) -> bool:
+ """Check if a part is a tool return using duck-typing.
+
+ Handles various runtime representations:
+ - ToolReturnPart (standard pydantic-ai)
+ - ToolReturn (streaming/runtime variant)
+ - Any object with tool_call_id + (content or return_value)
+ """
+ class_name = type(part).__name__
+
+ # Fast path: known class names
+ if class_name in _PART_TYPE_TOOL_RETURN:
+ return True
+
+ # Duck-typing fallback: must have tool_call_id and some result content
+ has_tool_call_id = hasattr(part, "tool_call_id") and getattr(
+ part, "tool_call_id", None
+ )
+ has_content = hasattr(part, "content") or hasattr(part, "return_value")
+
+ return bool(has_tool_call_id and has_content)
+
+
+def _extract_tool_return_data(part: Any) -> dict[str, Any]:
+ """Extract tool return data from a part using multiple field strategies.
+
+ Handles different field naming conventions:
+ - content (ToolReturnPart)
+ - return_value (some runtime variants)
+ - result (potential future variants)
+ """
+ import json
+
+ # Get the result content - try multiple field names
+ raw_content = None
+ for field in ("content", "return_value", "result"):
+ if hasattr(part, field):
+ raw_content = getattr(part, field, None)
+ if raw_content is not None:
+ break
+
+ # Parse JSON if string, otherwise use as-is
+ if raw_content is None:
+ result_val = None
+ elif isinstance(raw_content, str):
+ try:
+ result_val = json.loads(raw_content)
+ except (json.JSONDecodeError, TypeError):
+ result_val = raw_content
+ else:
+ result_val = raw_content
+
+ # Get tool name - try multiple field names
+ tool_name = "unknown"
+ for field in ("tool_name", "name"):
+ if hasattr(part, field):
+ name = getattr(part, field, None)
+ if name:
+ tool_name = name
+ break
+
+ return {
+ "id": getattr(part, "tool_call_id", "") or "",
+ "name": tool_name,
+ "result": result_val,
+ }
+
+
+def extract_tool_returns(msg: Any) -> list[dict[str, Any]]:
+ """Extract tool return entries from a ModelRequest.
+
+ Returns list of dicts: {id, name, result}
+
+ Handles multiple runtime representations:
+ - ToolReturnPart (standard pydantic-ai)
+ - ToolReturn (streaming variant)
+ - Duck-typed objects with tool_call_id + content/return_value
+
+ This extended compatibility ensures tool results are persisted to SQLite
+ regardless of which pydantic-ai version or streaming mode is in use.
+ """
+ parts = getattr(msg, "parts", [])
+ result = []
+
+ for part in parts:
+ if not _is_tool_return_like(part):
+ continue
+
+ try:
+ data = _extract_tool_return_data(part)
+ if data["id"]: # Only add if we have a valid tool_call_id
+ result.append(data)
+ else:
+ logger.debug(
+ "Skipping tool return with empty tool_call_id: %s",
+ type(part).__name__,
+ )
+ except Exception as exc:
+ logger.warning(
+ "Failed to extract tool return from %s: %s", type(part).__name__, exc
+ )
+
+ return result
diff --git a/code_puppy/api/db/queries.py b/code_puppy/api/db/queries.py
new file mode 100644
index 000000000..b5adc75e2
--- /dev/null
+++ b/code_puppy/api/db/queries.py
@@ -0,0 +1,1269 @@
+"""Typed async query helpers for the shared chat_messages.db.
+
+All functions are coroutines. aiosqlite serialises writes internally via its
+own background thread — no threading.Lock is needed here.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from typing import Any, Optional
+
+from code_puppy.api.db.connection import get_db
+
+logger = logging.getLogger(__name__)
+
+
+# ---------------------------------------------------------------------------
+# Sessions
+# ---------------------------------------------------------------------------
+
+
+async def session_exists(session_id: str) -> bool:
+ """Return True if a session row already exists (regardless of deleted_at)."""
+ db = get_db()
+ cursor = await db.execute(
+ "SELECT 1 FROM sessions WHERE session_id = ? LIMIT 1", (session_id,)
+ )
+ row = await cursor.fetchone()
+ return row is not None
+
+
+async def get_session_row(session_id: str) -> Optional[dict]:
+ """Return the full session row as a plain dict, or None if not found.
+
+ Non-fatal: returns None on any exception (DB unavailable, etc.).
+ """
+ try:
+ db = get_db()
+ cursor = await db.execute(
+ "SELECT * FROM sessions WHERE session_id = ?", (session_id,)
+ )
+ row = await cursor.fetchone()
+ if row is None:
+ return None
+ return dict(row)
+ except Exception:
+ return None
+
+
+async def get_session_metadata(session_id: str) -> Optional[dict]:
+ """Return a subset of session columns useful for the chat handler.
+
+ Returns keys: session_id, title, agent_name, model_name,
+ working_directory, pinned, created_at — or None if not found.
+ Non-fatal: returns None on any exception.
+ """
+ try:
+ db = get_db()
+ cursor = await db.execute(
+ """
+ SELECT session_id, title, agent_name, model_name,
+ working_directory, pinned, created_at
+ FROM sessions
+ WHERE session_id = ?
+ """,
+ (session_id,),
+ )
+ row = await cursor.fetchone()
+ if row is None:
+ return None
+ return dict(row)
+ except Exception:
+ return None
+
+
+async def upsert_session(
+ *,
+ session_id: str,
+ title: str = "",
+ agent_name: str = "code-puppy",
+ model_name: str = "",
+ working_directory: str = "",
+ pinned: bool = False,
+ created_at: str,
+ updated_at: str,
+ message_count: int = 0,
+ total_tokens: int = 0,
+ deleted_at: Optional[str] = None,
+) -> None:
+ """Insert or replace a session row."""
+ db = get_db()
+ try:
+ await db.execute(
+ """
+ INSERT INTO sessions
+ (session_id, title, agent_name, model_name, working_directory,
+ pinned, created_at, updated_at, message_count, total_tokens, deleted_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(session_id) DO UPDATE SET
+ title = excluded.title,
+ agent_name = excluded.agent_name,
+ model_name = excluded.model_name,
+ working_directory = excluded.working_directory,
+ pinned = excluded.pinned,
+ updated_at = excluded.updated_at,
+ message_count = excluded.message_count,
+ total_tokens = excluded.total_tokens,
+ deleted_at = COALESCE(sessions.deleted_at, excluded.deleted_at)
+ """,
+ (
+ session_id,
+ title,
+ agent_name,
+ model_name,
+ working_directory,
+ 1 if pinned else 0,
+ created_at,
+ updated_at,
+ message_count,
+ total_tokens,
+ deleted_at,
+ ),
+ )
+ await db.commit()
+ except Exception:
+ await db.rollback()
+ raise
+
+
+async def update_session_stats(
+ session_id: str,
+ *,
+ message_count: int,
+ total_tokens: int,
+ updated_at: str,
+) -> None:
+ """Bump message_count, total_tokens, and updated_at after a save."""
+ db = get_db()
+ try:
+ await db.execute(
+ """
+ UPDATE sessions
+ SET message_count = ?, total_tokens = ?, updated_at = ?
+ WHERE session_id = ?
+ """,
+ (message_count, total_tokens, updated_at, session_id),
+ )
+ await db.commit()
+ except Exception:
+ await db.rollback()
+ raise
+
+
+async def update_session_working_directory(
+ session_id: str,
+ working_directory: str,
+ updated_at: str,
+) -> None:
+ """Update only the working_directory and updated_at columns for a session.
+
+ Narrow UPDATE — does not touch title, agent, model, pinned, counts, etc.
+ Called after a successful set_working_directory WS message.
+ """
+ db = get_db()
+ await db.execute(
+ "UPDATE sessions SET working_directory = ?, updated_at = ? WHERE session_id = ?",
+ (working_directory, updated_at, session_id),
+ )
+ await db.commit()
+
+
+async def update_session_meta_fields(
+ session_id: str,
+ *,
+ title: str,
+ pinned: bool,
+ updated_at: str,
+) -> None:
+ """Update only title, pinned, and updated_at for an existing session.
+
+ Narrow UPDATE — does NOT touch message_count, total_tokens, agent_name,
+ model_name, or working_directory. Called by the update_session_meta WS
+ message handler (user renames or pins/unpins a session).
+ """
+ db = get_db()
+ await db.execute(
+ "UPDATE sessions SET title = ?, pinned = ?, updated_at = ? WHERE session_id = ?",
+ (title, 1 if pinned else 0, updated_at, session_id),
+ )
+ await db.commit()
+
+
+async def soft_delete_session(session_id: str, deleted_at: str) -> None:
+ """Set deleted_at on a session (soft delete). Data is preserved."""
+ db = get_db()
+ await db.execute(
+ "UPDATE sessions SET deleted_at = ? WHERE session_id = ?",
+ (deleted_at, session_id),
+ )
+ await db.commit()
+
+
+# ---------------------------------------------------------------------------
+# Messages
+# ---------------------------------------------------------------------------
+
+
+async def get_next_seq(session_id: str) -> int:
+ """Return the next available seq number for a session (1-based)."""
+ db = get_db()
+ cursor = await db.execute(
+ "SELECT COALESCE(MAX(seq), 0) FROM messages WHERE session_id = ?",
+ (session_id,),
+ )
+ row = await cursor.fetchone()
+ return (row[0] or 0) + 1
+
+
+async def _insert_immediate_message_and_sync_session(
+ *,
+ session_id: str,
+ role: str,
+ content: str,
+ type: str,
+ agent_name: str = "",
+ model_name: str = "",
+ timestamp: str,
+ thinking: Optional[str] = None,
+ attachments_json: Optional[str] = None,
+ clean_content: Optional[str] = None,
+ system_message_type: Optional[str] = None,
+ system_message_path: Optional[str] = None,
+ token_count: int = 0,
+ increment_message_count: bool = True,
+) -> int:
+ """Insert an immediate WS message and sync session metadata atomically.
+
+ This path is used for system/error frames that must persist immediately,
+ outside the normal end-of-turn batch save. The session row is created if
+ needed, the next seq is allocated atomically, the message row is inserted,
+ and session metadata is updated in the same transaction so
+ message_count/updated_at cannot drift.
+
+ increment_message_count controls whether the sessions.message_count should
+ track this row. Persisted system banners/config rows should not affect the
+ user-visible chat turn count, while persisted error rows should.
+ """
+ db = get_db()
+ try:
+ await db.execute("BEGIN IMMEDIATE")
+ cursor = await db.execute(
+ "SELECT 1 FROM sessions WHERE session_id = ?", (session_id,)
+ )
+ existing = await cursor.fetchone()
+ if not existing:
+ await db.execute(
+ """
+ INSERT INTO sessions
+ (session_id, title, agent_name, model_name, working_directory,
+ pinned, created_at, updated_at, message_count, total_tokens, deleted_at)
+ VALUES (?, '', ?, ?, '', 0, ?, ?, 0, 0, NULL)
+ """,
+ (
+ session_id,
+ agent_name or "code-puppy",
+ model_name or "",
+ timestamp,
+ timestamp,
+ ),
+ )
+
+ cursor = await db.execute(
+ "SELECT COALESCE(MAX(seq), 0) + 1 AS next_seq FROM messages WHERE session_id = ?",
+ (session_id,),
+ )
+ seq_row = await cursor.fetchone()
+ seq = int(seq_row["next_seq"] if seq_row is not None else 1)
+
+ await db.execute(
+ """
+ INSERT INTO messages
+ (session_id, seq, role, content, type, agent_name, model_name,
+ timestamp, thinking, attachments_json, clean_content,
+ system_message_type, system_message_path,
+ token_count, compacted, pydantic_json, compaction_log_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, NULL)
+ """,
+ (
+ session_id,
+ seq,
+ role,
+ content,
+ type,
+ agent_name,
+ model_name,
+ timestamp,
+ thinking,
+ attachments_json,
+ clean_content,
+ system_message_type,
+ system_message_path,
+ token_count,
+ ),
+ )
+ await db.execute(
+ """
+ UPDATE sessions
+ SET message_count = COALESCE(message_count, 0) + ?,
+ total_tokens = COALESCE(total_tokens, 0) + ?,
+ updated_at = ?
+ WHERE session_id = ?
+ """,
+ (
+ 1 if increment_message_count else 0,
+ token_count,
+ timestamp,
+ session_id,
+ ),
+ )
+ await db.commit()
+ return seq
+ except Exception:
+ await db.rollback()
+ raise
+
+
+async def write_system_message_to_sqlite(
+ *,
+ session_id: str,
+ system_message_type: str,
+ content: str,
+ system_message_path: str = "",
+ agent_name: str = "",
+ model_name: str = "",
+ timestamp: Optional[str] = None,
+) -> None:
+ """Write a single system-event row to SQLite immediately.
+
+ Used by the WS chat handler to persist agent/model switches, CWD changes,
+ and new-session initialisation messages so the FE cold-load path sees them
+ without relying on ephemeral WS-only state.
+
+ For directory banners, deduplicates to avoid multiple banners for the same path.
+ """
+ import datetime as _dt
+
+ ts = timestamp or _dt.datetime.now(_dt.timezone.utc).isoformat()
+
+ # Dedup: for directory system messages, check if one already exists with same path
+ if system_message_type == "directory" and system_message_path:
+ try:
+ db = get_db()
+ cursor = await db.execute(
+ """
+ SELECT 1 FROM messages
+ WHERE session_id = ?
+ AND system_message_type = 'directory'
+ AND system_message_path = ?
+ LIMIT 1
+ """,
+ (session_id, system_message_path),
+ )
+ existing = await cursor.fetchone()
+ if existing:
+ import logging
+
+ logging.getLogger(__name__).debug(
+ "Skipping duplicate directory banner: session=%s path=%s",
+ session_id,
+ system_message_path,
+ )
+ return # Already have a banner for this path
+ except Exception as e:
+ import logging
+
+ logging.getLogger(__name__).warning("Directory dedup check failed: %s", e)
+ # Continue with insert on error
+
+ try:
+ await _insert_immediate_message_and_sync_session(
+ session_id=session_id,
+ role="system",
+ content=content,
+ type="system",
+ agent_name=agent_name,
+ model_name=model_name,
+ timestamp=ts,
+ system_message_type=system_message_type,
+ system_message_path=system_message_path,
+ increment_message_count=False,
+ )
+ except Exception as exc:
+ import logging
+
+ logging.getLogger(__name__).warning(
+ "write_system_message_to_sqlite failed (session=%s type=%s): %s",
+ session_id,
+ system_message_type,
+ exc,
+ exc_info=True,
+ )
+
+
+async def write_error_message_to_sqlite(
+ *,
+ session_id: str,
+ error: str,
+ error_type: str = "unknown",
+ technical_details: str = "",
+ action_required: Optional[str] = None,
+ agent_name: str = "",
+ model_name: str = "",
+ timestamp: Optional[str] = None,
+) -> None:
+ """Write a structured error row to SQLite immediately.
+
+ Error rows are stored as regular message rows with type='error' so they
+ survive session reloads. The structured payload is serialized into
+ attachments_json for the read path to reconstruct for the frontend.
+ """
+ import datetime as _dt
+
+ ts = timestamp or _dt.datetime.now(_dt.timezone.utc).isoformat()
+ payload = {
+ "error": error,
+ "error_type": error_type,
+ "technical_details": technical_details,
+ "action_required": action_required,
+ "session_id": session_id,
+ }
+
+ try:
+ await _insert_immediate_message_and_sync_session(
+ session_id=session_id,
+ role="system",
+ content=error,
+ type="error",
+ agent_name=agent_name,
+ model_name=model_name,
+ timestamp=ts,
+ clean_content=error,
+ attachments_json=json.dumps(payload),
+ )
+ except Exception as exc:
+ logging.getLogger(__name__).warning(
+ "write_error_message_to_sqlite failed (session=%s error_type=%s): %s",
+ session_id,
+ error_type,
+ exc,
+ exc_info=True,
+ )
+
+
+async def insert_message(
+ *,
+ session_id: str,
+ seq: int,
+ role: str,
+ content: str = "",
+ type: str = "",
+ agent_name: str = "",
+ model_name: str = "",
+ timestamp: str,
+ thinking: Optional[str] = None,
+ attachments_json: Optional[str] = None,
+ clean_content: Optional[str] = None,
+ system_message_type: Optional[str] = None,
+ system_message_path: Optional[str] = None,
+ token_count: int = 0,
+ compacted: int = 0,
+ pydantic_json: Optional[str] = None,
+ compaction_log_id: Optional[int] = None,
+) -> None:
+ """Insert a single message row."""
+ # Guard: skip empty visible content for user/assistant roles
+ # unless attachments are present (non-text payloads are still valid).
+ if (
+ role in ("user", "assistant")
+ and not content.strip()
+ and not attachments_json
+ and not pydantic_json
+ ):
+ logger.debug(
+ "insert_message: skipping empty %s message for session=%s seq=%d",
+ role,
+ session_id,
+ seq,
+ )
+ return
+
+ db = get_db()
+ await db.execute(
+ """
+ INSERT OR IGNORE INTO messages
+ (session_id, seq, role, content, type, agent_name, model_name,
+ timestamp, thinking, attachments_json, clean_content,
+ system_message_type, system_message_path,
+ token_count, compacted, pydantic_json, compaction_log_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ session_id,
+ seq,
+ role,
+ content,
+ type,
+ agent_name,
+ model_name,
+ timestamp,
+ thinking,
+ attachments_json,
+ clean_content,
+ system_message_type,
+ system_message_path,
+ token_count,
+ compacted,
+ pydantic_json,
+ compaction_log_id,
+ ),
+ )
+ await db.commit()
+
+
+async def insert_messages_batch(rows: list[dict[str, Any]]) -> None:
+ """Insert multiple message rows in a single transaction.
+
+ Each dict in *rows* must have all the same keys as insert_message kwargs.
+ Uses OR IGNORE so duplicate (session_id, seq) pairs are skipped silently.
+ Rolls back on any failure so later immediate error/system writes can still
+ open a fresh transaction.
+ """
+ if not rows:
+ return
+
+ # Guard: filter out empty visible content for user/assistant roles,
+ # unless attachments are present (non-text payloads are still valid).
+ original_count = len(rows)
+ rows = [
+ r
+ for r in rows
+ if not (
+ r.get("role") in ("user", "assistant")
+ and not r.get("content", "").strip()
+ and not r.get("attachments_json")
+ and not r.get("pydantic_json")
+ )
+ ]
+ dropped = original_count - len(rows)
+ if dropped > 0:
+ logger.info(
+ "insert_messages_batch: filtered %d empty user/assistant rows",
+ dropped,
+ )
+ if not rows:
+ return
+
+ db = get_db()
+ params = [
+ (
+ r["session_id"],
+ r["seq"],
+ r["role"],
+ r.get("content", ""),
+ r.get("type", ""),
+ r.get("agent_name", ""),
+ r.get("model_name", ""),
+ r["timestamp"],
+ r.get("thinking"),
+ r.get("attachments_json"),
+ r.get("clean_content"),
+ r.get("system_message_type"),
+ r.get("system_message_path"),
+ r.get("token_count", 0),
+ r.get("compacted", 0),
+ r.get("pydantic_json"),
+ r.get("compaction_log_id"),
+ )
+ for r in rows
+ ]
+ try:
+ await db.executemany(
+ """
+ INSERT OR IGNORE INTO messages
+ (session_id, seq, role, content, type, agent_name, model_name,
+ timestamp, thinking, attachments_json, clean_content,
+ system_message_type, system_message_path,
+ token_count, compacted, pydantic_json, compaction_log_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ params,
+ )
+ await db.commit()
+ except Exception:
+ await db.rollback()
+ raise
+
+
+async def get_active_messages(session_id: str) -> list[dict[str, Any]]:
+ """Return all non-compacted message rows for a session, ordered by seq.
+
+ This is what the agent uses to reconstruct its context via pydantic_json.
+ """
+ db = get_db()
+ cursor = await db.execute(
+ """
+ SELECT session_id, seq, role, content, type, agent_name, model_name,
+ timestamp, thinking, attachments_json, clean_content,
+ system_message_type, system_message_path,
+ token_count, compacted, pydantic_json, compaction_log_id
+ FROM messages
+ WHERE session_id = ? AND compacted = 0
+ ORDER BY seq
+ """,
+ (session_id,),
+ )
+ rows = await cursor.fetchall()
+ return [dict(r) for r in rows]
+
+
+# ---------------------------------------------------------------------------
+# Tool calls
+# ---------------------------------------------------------------------------
+
+
+async def insert_tool_call(
+ *,
+ id: str,
+ session_id: str,
+ parent_message_seq: Optional[int],
+ seq: int,
+ tool_name: str,
+ args_json: Optional[str] = None,
+ result_json: Optional[str] = None,
+ status: str = "success",
+ duration_ms: Optional[int] = None,
+ error_text: Optional[str] = None,
+ agent_name: str = "",
+ model_name: str = "",
+ timestamp: float,
+) -> None:
+ db = get_db()
+ await db.execute(
+ """
+ INSERT OR IGNORE INTO tool_calls
+ (id, session_id, parent_message_seq, seq, tool_name,
+ args_json, result_json, status, duration_ms, error_text,
+ agent_name, model_name, timestamp)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ id,
+ session_id,
+ parent_message_seq,
+ seq,
+ tool_name,
+ args_json,
+ result_json,
+ status,
+ duration_ms,
+ error_text,
+ agent_name,
+ model_name,
+ timestamp,
+ ),
+ )
+ await db.commit()
+
+
+async def insert_tool_calls_batch(rows: list[dict[str, Any]]) -> None:
+ """Insert multiple tool call rows in a single transaction."""
+ if not rows:
+ return
+ db = get_db()
+ params = [
+ (
+ r["id"],
+ r["session_id"],
+ r.get("parent_message_seq"),
+ r["seq"],
+ r["tool_name"],
+ r.get("args_json"),
+ r.get("result_json"),
+ r.get("status", "success"),
+ r.get("duration_ms"),
+ r.get("error_text"),
+ r.get("agent_name", ""),
+ r.get("model_name", ""),
+ r["timestamp"],
+ )
+ for r in rows
+ ]
+ await db.executemany(
+ """
+ INSERT OR IGNORE INTO tool_calls
+ (id, session_id, parent_message_seq, seq, tool_name,
+ args_json, result_json, status, duration_ms, error_text,
+ agent_name, model_name, timestamp)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ params,
+ )
+ await db.commit()
+
+
+# ---------------------------------------------------------------------------
+# Compaction
+# ---------------------------------------------------------------------------
+
+
+async def mark_messages_compacted(
+ session_id: str, start_seq: int, end_seq: int
+) -> None:
+ """Soft-delete a range of messages by setting compacted=1."""
+ db = get_db()
+ await db.execute(
+ """
+ UPDATE messages SET compacted = 1
+ WHERE session_id = ? AND seq BETWEEN ? AND ? AND compacted = 0
+ """,
+ (session_id, start_seq, end_seq),
+ )
+ await db.commit()
+
+
+async def insert_compaction_log(
+ *,
+ session_id: str,
+ summary_text: str,
+ source_start: int,
+ source_end: int,
+ source_count: int,
+ source_tokens: int,
+ summary_tokens: int,
+ strategy: str = "summarization",
+ created_at: str,
+) -> int:
+ """Insert a compaction log entry and return its id."""
+ db = get_db()
+ cursor = await db.execute(
+ """
+ INSERT INTO compaction_log
+ (session_id, summary_text, source_start, source_end,
+ source_count, source_tokens, summary_tokens, strategy, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ session_id,
+ summary_text,
+ source_start,
+ source_end,
+ source_count,
+ source_tokens,
+ summary_tokens,
+ strategy,
+ created_at,
+ ),
+ )
+ await db.commit()
+ return cursor.lastrowid # type: ignore[return-value]
+
+
+# ---------------------------------------------------------------------------
+# Per-turn chat write
+# ---------------------------------------------------------------------------
+
+
+async def write_turn_to_sqlite(
+ *,
+ session_id: str,
+ enhanced_history: list[Any],
+ title: str = "",
+ working_directory: str = "",
+ pinned: bool = False,
+ agent_name: str = "code-puppy",
+ model_name: str = "",
+ total_tokens: int = 0,
+ updated_at: str,
+ created_at: str,
+ ctx: Any,
+) -> None:
+ """Write an entire enhanced_history to SQLite after a chat turn.
+
+ Called from chat_handler.py after save_session() succeeds.
+ Uses INSERT OR IGNORE throughout — fully idempotent.
+
+ Message seq: For non-user messages, we query the current max seq from the DB
+ and increment from there. User messages are skipped (already written pre-stream
+ via insert_message() with get_next_seq()).
+ Tool call seq: max_message_seq + tc_global_idx + 1 (after all messages).
+ Tool calls carry parent_message_seq so the FE doesn't need to infer it.
+
+ Args:
+ enhanced_history: List of dicts (already-wrapped format from chat_handler):
+ {'msg': ModelMessage, 'agent': str, 'model': str, 'ts': str}
+ Possibly also: {'clean_content': str, 'attachments': [...]}
+ ctx: SessionContext — used for estimate_tokens_for_message().
+ Pass None to use a simple len(content)//4 fallback.
+ """
+ import re
+ import time
+ from datetime import datetime
+
+ from code_puppy.api.db.message_utils import (
+ extract_content,
+ extract_thinking,
+ extract_tool_calls,
+ extract_tool_returns,
+ get_message_timestamp,
+ get_role,
+ pydantic_json_for_message,
+ )
+
+ # Upsert session first
+ try:
+ await upsert_session(
+ session_id=session_id,
+ title=title,
+ agent_name=agent_name,
+ model_name=model_name,
+ working_directory=working_directory,
+ pinned=pinned,
+ created_at=created_at,
+ updated_at=updated_at,
+ message_count=len(enhanced_history),
+ total_tokens=total_tokens,
+ deleted_at=None,
+ )
+ except Exception as exc:
+ logger.warning("write_turn_to_sqlite: upsert_session failed: %s", exc)
+ return
+
+ # ------------------------------------------------------------------ #
+ # Build message rows and collect pending tool calls #
+ # ------------------------------------------------------------------ #
+ message_rows: list[dict[str, Any]] = []
+ # pending_tool_calls: tool_call_id → {name, args, parent_seq, agent, model, ts}
+ pending_tool_calls: dict[str, dict[str, Any]] = {}
+ tool_call_rows_pending: list[dict[str, Any]] = []
+
+ _ctx_re = re.compile(r"^\[Session Context:[^\]]*\]\n\n?")
+
+ # Get the current max seq from the DB so we can assign correct seq numbers
+ # to non-user messages. User messages are already written pre-stream with
+ # get_next_seq(), so we must not collide with them.
+ current_max_seq = await get_next_seq(session_id) - 1 # get_next_seq returns max+1
+
+ for idx, item in enumerate(enhanced_history):
+ # Unwrap the enhanced wrapper
+ if isinstance(item, dict) and "msg" in item:
+ actual_msg = item["msg"]
+ wrapper = item
+ else:
+ actual_msg = item
+ wrapper = {}
+
+ if not hasattr(actual_msg, "parts"):
+ continue # skip system dict entries
+
+ role = get_role(actual_msg)
+ ts = wrapper.get("ts") or get_message_timestamp(actual_msg) or updated_at
+ content = extract_content(actual_msg)
+
+ if role in {"user", "assistant"} and not content.strip():
+ diag_tool_calls_present = bool(extract_tool_calls(actual_msg))
+ diag_tool_returns_present = bool(extract_tool_returns(actual_msg))
+ logger.debug(
+ "write_turn_to_sqlite empty visible content: "
+ "session=%s role=%s history_idx=%d has_tool_calls=%s has_tool_returns=%s",
+ session_id,
+ role,
+ idx,
+ diag_tool_calls_present,
+ diag_tool_returns_present,
+ )
+
+ # Extract tool returns from user (ModelRequest) messages BEFORE skipping.
+ # BUG FIX: previously this block was placed AFTER the early `continue`
+ # below, making it dead code — tool_call_rows_pending was never populated
+ # and insert_tool_calls_batch() always received an empty list.
+ if role == "user":
+ for tr in extract_tool_returns(actual_msg):
+ tid = tr["id"]
+ if tid in pending_tool_calls:
+ tc = pending_tool_calls.pop(tid)
+ try:
+ args_json_str = json.dumps(tc["args"])
+ except Exception:
+ args_json_str = str(tc["args"])
+ # Serialize result - handle Pydantic models and complex objects
+ result_val = tr["result"]
+ try:
+ # Try Pydantic model_dump first
+ if hasattr(result_val, "model_dump"):
+ result_json_str = json.dumps(result_val.model_dump())
+ elif hasattr(result_val, "dict"):
+ # Older Pydantic v1 style
+ result_json_str = json.dumps(result_val.dict())
+ elif hasattr(result_val, "__dict__"):
+ # Generic object - convert to dict
+ result_json_str = json.dumps(vars(result_val))
+ else:
+ result_json_str = json.dumps(result_val)
+ except Exception:
+ # Last resort - try to make it JSON-serializable
+ try:
+ result_json_str = json.dumps(str(result_val))
+ except Exception:
+ result_json_str = json.dumps({"raw": str(result_val)})
+
+ try:
+ tool_ts = datetime.fromisoformat(
+ str(tc["ts"]).replace("Z", "+00:00")
+ ).timestamp()
+ except Exception:
+ tool_ts = time.time()
+
+ tool_call_rows_pending.append(
+ {
+ "id": tid,
+ "session_id": session_id,
+ "parent_message_seq": tc["parent_seq"],
+ "tool_name": tc["name"],
+ "args_json": args_json_str,
+ "result_json": result_json_str,
+ "status": "success",
+ "agent_name": tc["agent"],
+ "model_name": tc["model"],
+ "timestamp": tool_ts,
+ }
+ )
+ # Skip writing user message rows — already pre-written by
+ # chat_handler.py via insert_message()/get_next_seq(). Writing
+ # them again here would create duplicates.
+ continue
+
+ # Assign seq for non-user messages: increment from current max
+ current_max_seq += 1
+ seq = current_max_seq
+
+ thinking = extract_thinking(actual_msg)
+ pj = pydantic_json_for_message(actual_msg)
+
+ msg_agent = wrapper.get("agent") or agent_name or "code-puppy"
+ msg_model = wrapper.get("model") or model_name or "unknown"
+
+ # clean_content: prefer wrapper value, then strip session context for user
+ clean_content: str | None = wrapper.get("clean_content") or None
+ if clean_content is None and role == "user" and content:
+ stripped = _ctx_re.sub("", content).lstrip()
+ if stripped != content:
+ clean_content = stripped
+
+ # attachments_json: from wrapper
+ raw_attachments = wrapper.get("attachments")
+ attachments_json: str | None = (
+ json.dumps(raw_attachments) if raw_attachments else None
+ )
+
+ # token count
+ try:
+ token_count = (
+ ctx.agent.estimate_tokens_for_message(actual_msg)
+ if ctx
+ else max(1, len(content) // 4)
+ )
+ except Exception:
+ token_count = max(1, len(content) // 4)
+
+ assistant_tool_calls = (
+ extract_tool_calls(actual_msg) if role == "assistant" else []
+ )
+
+ # Preserve non-text assistant payloads (tool-call-only responses).
+ # This keeps the row from being filtered by insert_messages_batch.
+ if role == "assistant" and assistant_tool_calls and not attachments_json:
+ attachments_json = json.dumps({"tool_calls": assistant_tool_calls})
+
+ # Guard: skip only truly empty assistant messages
+ # (no text AND no tool calls).
+ if role == "assistant" and not content.strip() and not assistant_tool_calls:
+ logger.debug(
+ "write_turn_to_sqlite: skipping empty assistant message "
+ "session=%s history_idx=%d (no text, no tool calls)",
+ session_id,
+ idx,
+ )
+ continue # truly empty - skip
+
+ message_rows.append(
+ {
+ "session_id": session_id,
+ "seq": seq,
+ "role": role,
+ "content": content,
+ "type": type(actual_msg).__name__,
+ "agent_name": msg_agent,
+ "model_name": msg_model,
+ "timestamp": ts,
+ "thinking": thinking,
+ "attachments_json": attachments_json,
+ "clean_content": clean_content,
+ "token_count": token_count,
+ "pydantic_json": pj,
+ }
+ )
+
+ # Collect tool calls from assistant messages
+ if role == "assistant":
+ for tc in assistant_tool_calls:
+ pending_tool_calls[tc["id"]] = {
+ "name": tc["name"],
+ "args": tc["args"],
+ "parent_seq": seq,
+ "agent": msg_agent,
+ "model": msg_model,
+ "ts": ts,
+ }
+
+ # Assign seqs to tool calls: current_max_seq + 1, current_max_seq + 2, ...
+ # (current_max_seq was already incremented for each message we added)
+ tool_call_rows: list[dict[str, Any]] = []
+ for tc_idx, tc_row in enumerate(tool_call_rows_pending):
+ tc_row["seq"] = current_max_seq + tc_idx + 1
+ tool_call_rows.append(tc_row)
+
+ # Write everything
+ try:
+ await insert_messages_batch(message_rows)
+ except Exception as exc:
+ logger.warning("write_turn_to_sqlite: insert_messages_batch failed: %s", exc)
+
+ if tool_call_rows:
+ try:
+ await insert_tool_calls_batch(tool_call_rows)
+ except Exception as exc:
+ logger.warning(
+ "write_turn_to_sqlite: insert_tool_calls_batch failed: %s", exc
+ )
+
+ # Update session stats
+ computed_tokens = sum(r.get("token_count", 0) for r in message_rows)
+ final_tokens = total_tokens if total_tokens > 0 else computed_tokens
+ try:
+ await update_session_stats(
+ session_id,
+ message_count=len(message_rows),
+ total_tokens=final_tokens,
+ updated_at=updated_at,
+ )
+ except Exception as exc:
+ logger.warning("write_turn_to_sqlite: update_session_stats failed: %s", exc)
+
+ # Log with diagnostic counts for forensic debugging
+ unmatched_calls = len(pending_tool_calls)
+ logger.info(
+ "write_turn_to_sqlite: session=%s messages=%d tool_calls=%d pending_unmatched=%d",
+ session_id,
+ len(message_rows),
+ len(tool_call_rows),
+ unmatched_calls,
+ )
+
+ # Warn if tool calls were detected but not matched to returns
+ if unmatched_calls > 0:
+ logger.warning(
+ "write_turn_to_sqlite: %d tool calls not matched to returns for session=%s: %s",
+ unmatched_calls,
+ session_id,
+ list(pending_tool_calls.keys()),
+ )
+
+
+# ---------------------------------------------------------------------------
+# Interleaved session history query (Full Parity Model)
+#
+# Returns messages + tool_calls in a single ordered result set with a
+# row_type discriminator, matching the desktop GUI's SESSION_LOAD_SQL.
+# This enables the browser API to return the same data shape as the desktop.
+# ---------------------------------------------------------------------------
+
+# SQL query for interleaved messages + tool_calls, ordered by seq
+_SESSION_HISTORY_PARITY_SQL = """
+SELECT
+ 'message' AS row_type,
+ CAST(m.id AS TEXT) AS row_id,
+ m.seq,
+ m.role,
+ m.content,
+ m.type,
+ m.agent_name,
+ m.model_name,
+ m.timestamp,
+ m.thinking,
+ m.attachments_json,
+ m.clean_content,
+ m.system_message_type,
+ m.system_message_path,
+ m.token_count,
+ m.compacted,
+ m.compaction_log_id,
+ NULL AS tool_name,
+ NULL AS args_json,
+ NULL AS result_json,
+ NULL AS status,
+ NULL AS duration_ms,
+ NULL AS error_text,
+ NULL AS parent_message_seq
+FROM messages m
+WHERE m.session_id = ?
+
+UNION ALL
+
+SELECT
+ 'tool_call' AS row_type,
+ tc.id AS row_id,
+ tc.seq,
+ 'tool' AS role,
+ NULL AS content,
+ NULL AS type,
+ tc.agent_name,
+ tc.model_name,
+ CAST(tc.timestamp AS TEXT) AS timestamp,
+ NULL AS thinking,
+ NULL AS attachments_json,
+ NULL AS clean_content,
+ NULL AS system_message_type,
+ NULL AS system_message_path,
+ 0 AS token_count,
+ 0 AS compacted,
+ NULL AS compaction_log_id,
+ tc.tool_name,
+ tc.args_json,
+ tc.result_json,
+ tc.status,
+ tc.duration_ms,
+ tc.error_text,
+ tc.parent_message_seq
+FROM tool_calls tc
+WHERE tc.session_id = ?
+
+ORDER BY seq;
+"""
+
+
+async def get_session_history_parity(
+ session_id: str,
+ *,
+ include_compacted: bool = True,
+) -> list[dict[str, Any]]:
+ """Return interleaved messages + tool_calls for a session.
+
+ This is the Full Parity Model query — returns the same row shape as the
+ desktop GUI's SESSION_LOAD_SQL, enabling consistent behavior across:
+ - Desktop GUI (Electron)
+ - Browser chat UI
+ - API consumers
+
+ Args:
+ session_id: The session to load
+ include_compacted: If False, filter out compacted message rows
+ (tool_call rows are never compacted)
+
+ Returns:
+ List of dicts with keys:
+ - row_type: 'message' | 'tool_call'
+ - row_id: str (message id or tool_call id)
+ - seq: int (ordering key)
+ - role: str
+ - content, type, agent_name, model_name, timestamp, ...
+ - tool_name, args_json, result_json, status, ... (for tool_call rows)
+ - parent_message_seq: int | None (for tool_call rows)
+ """
+ db = get_db()
+
+ if include_compacted:
+ # Use the standard parity SQL
+ cursor = await db.execute(
+ _SESSION_HISTORY_PARITY_SQL,
+ (session_id, session_id),
+ )
+ else:
+ # Modify the query to exclude compacted messages
+ sql = """
+SELECT
+ 'message' AS row_type,
+ CAST(m.id AS TEXT) AS row_id,
+ m.seq,
+ m.role,
+ m.content,
+ m.type,
+ m.agent_name,
+ m.model_name,
+ m.timestamp,
+ m.thinking,
+ m.attachments_json,
+ m.clean_content,
+ m.system_message_type,
+ m.system_message_path,
+ m.token_count,
+ m.compacted,
+ m.compaction_log_id,
+ NULL AS tool_name,
+ NULL AS args_json,
+ NULL AS result_json,
+ NULL AS status,
+ NULL AS duration_ms,
+ NULL AS error_text,
+ NULL AS parent_message_seq
+FROM messages m
+WHERE m.session_id = ? AND m.compacted = 0
+
+UNION ALL
+
+SELECT
+ 'tool_call' AS row_type,
+ tc.id AS row_id,
+ tc.seq,
+ 'tool' AS role,
+ NULL AS content,
+ NULL AS type,
+ tc.agent_name,
+ tc.model_name,
+ CAST(tc.timestamp AS TEXT) AS timestamp,
+ NULL AS thinking,
+ NULL AS attachments_json,
+ NULL AS clean_content,
+ NULL AS system_message_type,
+ NULL AS system_message_path,
+ 0 AS token_count,
+ 0 AS compacted,
+ NULL AS compaction_log_id,
+ tc.tool_name,
+ tc.args_json,
+ tc.result_json,
+ tc.status,
+ tc.duration_ms,
+ tc.error_text,
+ tc.parent_message_seq
+FROM tool_calls tc
+WHERE tc.session_id = ?
+
+ORDER BY seq;
+"""
+ cursor = await db.execute(sql, (session_id, session_id))
+
+ rows = await cursor.fetchall()
+ return [dict(r) for r in rows]
+
+
+async def get_session_tool_calls(session_id: str) -> list[dict[str, Any]]:
+ """Return all tool_call rows for a session, ordered by seq.
+
+ Useful for diagnostics and validation.
+ """
+ db = get_db()
+ cursor = await db.execute(
+ """
+ SELECT id, session_id, parent_message_seq, seq, tool_name,
+ args_json, result_json, status, duration_ms, error_text,
+ agent_name, model_name, timestamp
+ FROM tool_calls
+ WHERE session_id = ?
+ ORDER BY seq
+ """,
+ (session_id,),
+ )
+ rows = await cursor.fetchall()
+ return [dict(r) for r in rows]
diff --git a/code_puppy/api/db/seeder.py b/code_puppy/api/db/seeder.py
new file mode 100644
index 000000000..0c5a5b2d3
--- /dev/null
+++ b/code_puppy/api/db/seeder.py
@@ -0,0 +1,555 @@
+"""Seed ~/.puppy_desk/chat_messages.db from existing pkl session files.
+
+Scans both:
+ ~/.code_puppy/ws_sessions/ — live WS sessions (HMAC-signed pickle)
+ ~/.code_puppy/autosaves/ — CLI autosave sessions (raw or legacy-signed pickle)
+
+For each .pkl file not already in the DB:
+ 1. Deserialises the pickle natively (no subprocess, no JSON round-trip)
+ 2. Serialises each ModelMessage with ModelMessagesTypeAdapter.dump_json()
+ → stored verbatim in the pydantic_json column for perfect replay
+ 3. Extracts display fields (role, content, thinking, tool calls) from message parts
+ 4. Writes session + messages + tool_calls in a single transaction
+
+Idempotent: existing sessions are skipped. Safe to call on every startup.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import hmac
+import json
+import logging
+import pickle
+import re
+import time
+import uuid
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any, Optional
+
+from code_puppy.api.db.message_utils import (
+ extract_content,
+ extract_thinking,
+ get_message_timestamp,
+ get_role,
+ pydantic_json_for_message,
+)
+
+logger = logging.getLogger(__name__)
+# ---------------------------------------------------------------------------
+# One-time migration marker — written after a successful complete seed.
+# On subsequent startups the seeder skips entirely (no more pkl to import).
+# ---------------------------------------------------------------------------
+_SEEDER_DONE_MARKER = Path("~/.puppy_desk/.seeder_migration_done").expanduser()
+
+
+# ---------------------------------------------------------------------------
+# Legacy pickle header constants (from session_storage.py)
+# ---------------------------------------------------------------------------
+
+_LEGACY_SIGNED_HEADER = b"CPSESSION\x01"
+_LEGACY_SIGNATURE_SIZE = 32
+
+
+def _get_hmac_key() -> bytes:
+ """Return HMAC key for verifying WS session pickle files.
+
+ Copied from session_context to make seeder.py self-contained after
+ _get_hmac_key was removed from session_context in desk-puppy-cr1x.
+ """
+ import os
+
+ key_env = os.environ.get("CODE_PUPPY_SESSION_KEY", "")
+ if key_env:
+ return key_env.encode()
+ # Fall back to a machine-stable key derived from the DB path
+ db_path = str(Path("~/.puppy_desk/chat_messages.db").expanduser())
+ return db_path.encode()
+
+
+def _extract_pickle_payload(raw: bytes) -> bytes:
+ """Strip legacy or new-format prefix and return the raw pickle bytes."""
+ if raw.startswith(_LEGACY_SIGNED_HEADER):
+ offset = len(_LEGACY_SIGNED_HEADER) + _LEGACY_SIGNATURE_SIZE
+ return raw[offset:]
+ return raw
+
+
+def _verify_and_load_ws_pickle(raw: bytes, hmac_key: bytes) -> Optional[list[Any]]:
+ """Load a WS session pickle after HMAC verification.
+
+ WS session pkls are written by session_context.py:
+ file = 32-byte-HMAC-SHA256 + pickle(history)
+
+ Returns None if verification fails or the file is too small.
+ """
+ if len(raw) < 33: # 32-byte sig + at least 1 byte of pickle
+ return None
+ sig, blob = raw[:32], raw[32:]
+ expected = hmac.new(hmac_key, blob, hashlib.sha256).digest()
+ if not hmac.compare_digest(sig, expected):
+ logger.warning("HMAC verification failed — skipping")
+ return None
+ try:
+ data = pickle.loads(blob) # noqa: S301
+ return data if isinstance(data, list) else None
+ except Exception as exc:
+ logger.warning("Pickle load error: %s", exc)
+ return None
+
+
+def _load_autosave_pickle(raw: bytes) -> Optional[list[Any]]:
+ """Load an autosave pickle (no HMAC — strip legacy header if present)."""
+ payload = _extract_pickle_payload(raw)
+ try:
+ data = pickle.loads(payload) # noqa: S301
+ return data if isinstance(data, list) else None
+ except Exception as exc:
+ logger.warning("Autosave pickle load error: %s", exc)
+ return None
+
+
+# ---------------------------------------------------------------------------
+# Message part extraction helpers (content/thinking/role moved to message_utils)
+# ---------------------------------------------------------------------------
+
+_PART_TYPE_TOOL_CALL = {"ToolCallPart"}
+_PART_TYPE_TOOL_RETURN = {"ToolReturnPart"}
+
+
+def _extract_tool_calls(msg: Any) -> list[dict[str, Any]]:
+ """Extract ToolCallPart entries from a ModelResponse."""
+ parts = getattr(msg, "parts", [])
+ result = []
+ for part in parts:
+ if type(part).__name__ not in _PART_TYPE_TOOL_CALL:
+ continue
+ args = getattr(part, "args", {})
+ if hasattr(args, "model_dump"):
+ args = args.model_dump()
+ elif hasattr(args, "__dict__"):
+ args = dict(args.__dict__)
+ result.append(
+ {
+ "id": getattr(part, "tool_call_id", "") or str(uuid.uuid4()),
+ "name": getattr(part, "tool_name", "unknown"),
+ "args": args,
+ }
+ )
+ return result
+
+
+def _extract_tool_returns(msg: Any) -> list[dict[str, Any]]:
+ """Extract ToolReturnPart entries from a ModelRequest."""
+ parts = getattr(msg, "parts", [])
+ result = []
+ for part in parts:
+ if type(part).__name__ not in _PART_TYPE_TOOL_RETURN:
+ continue
+ content = getattr(part, "content", "")
+ try:
+ result_val = json.loads(content) if isinstance(content, str) else content
+ except Exception:
+ result_val = content
+ result.append(
+ {
+ "id": getattr(part, "tool_call_id", ""),
+ "name": getattr(part, "tool_name", "unknown"),
+ "result": result_val,
+ }
+ )
+ return result
+
+
+def _get_timestamp(msg: Any, wrapper: Optional[dict], fallback: str) -> str:
+ """Best-effort timestamp extraction."""
+ # Try wrapper ts first
+ if wrapper:
+ ts = wrapper.get("ts", "")
+ if ts:
+ return str(ts)
+ # Try message-level timestamp
+ ts_attr_result = get_message_timestamp(msg)
+ if ts_attr_result is not None:
+ return ts_attr_result
+ return fallback
+
+
+# ---------------------------------------------------------------------------
+# Per-session import
+# ---------------------------------------------------------------------------
+
+_SESSION_CONTEXT_RE = re.compile(r"^\[Session Context:[^\]]*\]\n\n?")
+
+
+def _strip_session_context(raw: str) -> Optional[str]:
+ cleaned = _SESSION_CONTEXT_RE.sub("", raw).lstrip()
+ return cleaned if cleaned != raw else None
+
+
+async def _import_session(
+ session_id: str,
+ history: list[Any],
+ meta: dict[str, Any],
+ now_iso: str,
+) -> int:
+ """Write one session and all its messages/tool_calls to the DB.
+
+ Returns the number of message rows written.
+ """
+ from code_puppy.api.db.queries import (
+ insert_messages_batch,
+ insert_tool_calls_batch,
+ upsert_session,
+ )
+
+ created_at = meta.get("timestamp") or now_iso
+ title = meta.get("title", "")
+ agent_name = meta.get("agent_name", "code-puppy")
+ model_name = meta.get("model_name", "")
+ working_directory = meta.get("working_directory", "")
+ pinned = bool(meta.get("pinned", False))
+ total_tokens = meta.get("total_tokens", 0)
+
+ await upsert_session(
+ session_id=session_id,
+ title=title,
+ agent_name=agent_name,
+ model_name=model_name,
+ working_directory=working_directory,
+ pinned=pinned,
+ created_at=created_at,
+ updated_at=created_at,
+ message_count=len(history),
+ total_tokens=total_tokens,
+ deleted_at=None,
+ )
+
+ message_rows: list[dict[str, Any]] = []
+ tool_call_rows: list[dict[str, Any]] = []
+ seq = 0
+ # Track pending tool calls by id for result matching
+ pending_tool_calls: dict[str, dict[str, Any]] = {}
+
+ for item in history:
+ # ---- Unwrap the item ----------------------------------------
+ wrapper: Optional[dict] = None
+ msg: Any
+
+ if isinstance(item, dict) and item.get("msg") == "system":
+ # System message in WS format: {'msg': 'system', 'content': ..., 'path': ...}
+ seq += 1
+ message_rows.append(
+ {
+ "session_id": session_id,
+ "seq": seq,
+ "role": "system",
+ "content": item.get("content", ""),
+ "type": "system",
+ "agent_name": item.get("agent", agent_name),
+ "model_name": item.get("model", model_name),
+ "timestamp": item.get("ts", now_iso),
+ "system_message_type": item.get("system_message_type", ""),
+ "system_message_path": item.get("path", ""),
+ "pydantic_json": None,
+ "token_count": 0,
+ }
+ )
+ continue
+
+ if isinstance(item, dict) and "msg" in item:
+ wrapper = item
+ msg = item["msg"]
+ else:
+ msg = item
+
+ # ---- Skip if not a pydantic-ai message ----------------------
+ if not hasattr(msg, "parts"):
+ continue
+
+ role = get_role(msg)
+ ts = _get_timestamp(msg, wrapper, now_iso)
+ content = extract_content(msg)
+ thinking = extract_thinking(msg)
+ pydantic_json = pydantic_json_for_message(msg)
+
+ msg_agent = wrapper.get("agent", agent_name) if wrapper else agent_name
+ msg_model = wrapper.get("model", model_name) if wrapper else model_name
+
+ # clean_content: strip [Session Context: ...] for user messages
+ clean_content: Optional[str] = None
+ raw_clean = wrapper.get("clean_content") if wrapper else None
+ if raw_clean:
+ clean_content = raw_clean
+ elif role == "user" and content:
+ clean_content = _strip_session_context(content)
+
+ # attachments_json
+ attachments = wrapper.get("attachments") if wrapper else None
+ attachments_json = json.dumps(attachments) if attachments else None
+
+ seq += 1
+ message_seq = seq
+
+ # ---- Estimate token count (simple char/4 heuristic) ---------
+ token_count = max(1, len(content) // 4)
+
+ message_rows.append(
+ {
+ "session_id": session_id,
+ "seq": message_seq,
+ "role": role,
+ "content": content,
+ "type": type(msg).__name__,
+ "agent_name": msg_agent,
+ "model_name": msg_model,
+ "timestamp": ts,
+ "thinking": thinking,
+ "attachments_json": attachments_json,
+ "clean_content": clean_content,
+ "pydantic_json": pydantic_json,
+ "token_count": token_count,
+ }
+ )
+
+ # ---- Tool calls from assistant messages ---------------------
+ if role == "assistant":
+ for tc in _extract_tool_calls(msg):
+ pending_tool_calls[tc["id"]] = {
+ "tc_id": tc["id"],
+ "name": tc["name"],
+ "args": tc["args"],
+ "parent_seq": message_seq,
+ "agent": msg_agent,
+ "model": msg_model,
+ "ts": ts,
+ }
+
+ # ---- Tool returns from user messages (Anthropic API format) -
+ if role == "user":
+ for tr in _extract_tool_returns(msg):
+ tid = tr["id"]
+ if tid in pending_tool_calls:
+ tc = pending_tool_calls.pop(tid)
+ seq += 1
+ try:
+ args_json = json.dumps(tc["args"])
+ except Exception:
+ args_json = str(tc["args"])
+ # Serialize result - handle Pydantic models and complex objects
+ result_val = tr["result"]
+ try:
+ if hasattr(result_val, "model_dump"):
+ result_json = json.dumps(result_val.model_dump())
+ elif hasattr(result_val, "dict"):
+ result_json = json.dumps(result_val.dict())
+ elif hasattr(result_val, "__dict__"):
+ result_json = json.dumps(vars(result_val))
+ else:
+ result_json = json.dumps(result_val)
+ except Exception:
+ try:
+ result_json = json.dumps(str(result_val))
+ except Exception:
+ result_json = json.dumps({"raw": str(result_val)})
+
+ tool_ts_str = tc.get("ts", ts)
+ try:
+ tool_ts = datetime.fromisoformat(
+ str(tool_ts_str).replace("Z", "+00:00")
+ ).timestamp()
+ except Exception:
+ tool_ts = time.time()
+
+ tool_call_rows.append(
+ {
+ "id": tid,
+ "session_id": session_id,
+ "parent_message_seq": tc["parent_seq"],
+ "seq": seq,
+ "tool_name": tc["name"],
+ "args_json": args_json,
+ "result_json": result_json,
+ "status": "success",
+ "agent_name": tc["agent"],
+ "model_name": tc["model"],
+ "timestamp": tool_ts,
+ }
+ )
+
+ # Write everything in batches (each batch uses its own transaction)
+ await insert_messages_batch(message_rows)
+ await insert_tool_calls_batch(tool_call_rows)
+ return len(message_rows)
+
+
+# ---------------------------------------------------------------------------
+# Directory scanners
+# ---------------------------------------------------------------------------
+
+
+def _load_meta(meta_path: Path) -> dict[str, Any]:
+ try:
+ if meta_path.exists():
+ return json.loads(meta_path.read_text(encoding="utf-8"))
+ except Exception:
+ pass
+ return {}
+
+
+async def _seed_directory(
+ sessions_dir: Path,
+ is_ws: bool,
+ hmac_key: Optional[bytes],
+ now_iso: str,
+) -> tuple[int, int, int]:
+ """Seed all pkl files from *sessions_dir*. Returns (imported, skipped, failed)."""
+ from code_puppy.api.db.queries import session_exists
+
+ if not sessions_dir.exists():
+ logger.debug("Sessions dir not found, skipping: %s", sessions_dir)
+ return 0, 0, 0
+
+ pkl_files = sorted(sessions_dir.glob("*.pkl"))
+ total = len(pkl_files)
+ if total == 0:
+ return 0, 0, 0
+
+ imported = skipped = failed = 0
+ width = len(str(total))
+
+ for i, pkl_path in enumerate(pkl_files, 1):
+ session_id = pkl_path.stem
+
+ # Skip tiny files (HMAC-only stubs, empty autosaves)
+ try:
+ if pkl_path.stat().st_size < 50:
+ skipped += 1
+ continue
+ except OSError:
+ skipped += 1
+ continue
+
+ if await session_exists(session_id):
+ skipped += 1
+ continue
+
+ try:
+ raw = pkl_path.read_bytes()
+ except OSError as exc:
+ logger.warning(
+ "[%*d/%d] %s — read error: %s", width, i, total, session_id, exc
+ )
+ failed += 1
+ continue
+
+ # Deserialise
+ if is_ws and hmac_key is not None:
+ history = _verify_and_load_ws_pickle(raw, hmac_key)
+ else:
+ history = _load_autosave_pickle(raw)
+
+ if not history:
+ skipped += 1
+ continue
+
+ # Load metadata
+ meta_path = pkl_path.with_name(f"{session_id}_meta.json")
+ meta = _load_meta(meta_path)
+
+ try:
+ n = await _import_session(session_id, history, meta, now_iso)
+ logger.info(
+ "[%*d/%d] %-50s %4d msgs — imported",
+ width,
+ i,
+ total,
+ session_id,
+ n,
+ )
+ imported += 1
+ except Exception as exc:
+ logger.error(
+ "[%*d/%d] %s — import error: %s",
+ width,
+ i,
+ total,
+ session_id,
+ exc,
+ exc_info=True,
+ )
+ failed += 1
+
+ return imported, skipped, failed
+
+
+# ---------------------------------------------------------------------------
+# Public entry point
+# ---------------------------------------------------------------------------
+
+
+async def seed_from_pkl_dirs() -> None:
+ """Asynchronously seed the DB from both ws_sessions and autosaves directories.
+
+ All database operations go through the shared aiosqlite connection so this
+ must run on the event loop that owns the DB connection. File I/O for
+ reading pickle files is blocking but acceptable at startup since the seeder
+ runs as a background asyncio.create_task().
+
+ Safe to call on every startup — already-imported sessions are skipped.
+ """
+ # One-time migration guard: if we already migrated all pkl files, skip.
+ if _SEEDER_DONE_MARKER.exists():
+ logger.debug("Seeder already completed (marker found) — skipping pkl scan")
+ return
+
+ from code_puppy.config import AUTOSAVE_DIR, WS_SESSION_DIR
+
+ now_iso = datetime.now(timezone.utc).isoformat()
+
+ # ---- WS sessions (HMAC-signed) -----------------------------------
+ ws_dir = Path(WS_SESSION_DIR)
+ hmac_key: Optional[bytes] = None
+ try:
+ hmac_key = _get_hmac_key()
+ except Exception as exc:
+ logger.warning("Could not load HMAC key, WS sessions will be skipped: %s", exc)
+
+ if hmac_key is not None:
+ logger.info("Seeding WS sessions from %s ...", ws_dir)
+ wi, ws, wf = await _seed_directory(
+ ws_dir, is_ws=True, hmac_key=hmac_key, now_iso=now_iso
+ )
+ logger.info("WS sessions: %d imported, %d skipped, %d failed", wi, ws, wf)
+ else:
+ wi = ws = wf = 0
+
+ # ---- Autosaves (no HMAC) -----------------------------------------
+ auto_dir = Path(AUTOSAVE_DIR)
+ logger.info("Seeding autosave sessions from %s ...", auto_dir)
+ ai, as_, af = await _seed_directory(
+ auto_dir, is_ws=False, hmac_key=None, now_iso=now_iso
+ )
+ logger.info("Autosave sessions: %d imported, %d skipped, %d failed", ai, as_, af)
+
+ total_imported = wi + ai
+ total_skipped = ws + as_
+ total_failed = wf + af
+ logger.info(
+ "\u2713 Seeding complete: %d imported, %d skipped, %d failed",
+ total_imported,
+ total_skipped,
+ total_failed,
+ )
+
+ # Write the one-time migration marker if seed succeeded with no failures.
+ if total_failed == 0:
+ try:
+ _SEEDER_DONE_MARKER.parent.mkdir(parents=True, exist_ok=True)
+ _SEEDER_DONE_MARKER.write_text(
+ f"Seeder migration completed. imported={total_imported} skipped={total_skipped}\n"
+ )
+ logger.info("✓ Seeder migration marker written to %s", _SEEDER_DONE_MARKER)
+ except Exception as exc:
+ logger.warning("Could not write seeder marker: %s", exc)
diff --git a/code_puppy/api/error_parser.py b/code_puppy/api/error_parser.py
new file mode 100644
index 000000000..ffd60fafe
--- /dev/null
+++ b/code_puppy/api/error_parser.py
@@ -0,0 +1,239 @@
+"""Error parsing utilities for API errors.
+
+Provides human-readable error messages and actionable guidance for common API errors.
+"""
+
+import logging
+import re
+from typing import Any, Dict
+
+logger = logging.getLogger(__name__)
+
+
+def parse_api_error(error: Exception) -> Dict[str, Any]:
+ """Parse API errors into user-friendly messages with actionable guidance.
+
+ Debugging: this function logs the chosen classification at DEBUG level
+ to help trace WS error propagation to the UI.
+
+ Args:
+ error: The exception that was raised
+
+ Returns:
+ Dictionary with:
+ - error_type: Category of error (quota_exceeded, rate_limit, auth_error, network_error, unknown)
+ - user_message: Human-readable message for the user
+ - technical_details: Technical error information
+ - action_required: Specific action the user should take (optional)
+ - original_error: String representation of the original error
+ """
+ error_str = str(error)
+ error_type_name = type(error).__name__
+
+ logger.debug(
+ "parse_api_error: incoming type=%s msg=%r",
+ error_type_name,
+ error_str[:400],
+ )
+
+ # Check for quota exceeded errors (HTTP 429 or explicit quota messages)
+ if _is_quota_exceeded_error(error_str):
+ parsed = {
+ "error_type": "quota_exceeded",
+ "user_message": "Your API quota has been exceeded for the current model. Please switch to a different model and click continue to retry your request.",
+ "technical_details": _extract_quota_details(error_str),
+ "action_required": "switch_model",
+ "original_error": error_str,
+ }
+ logger.debug("parse_api_error: classified as %s", parsed["error_type"])
+ return parsed
+
+ # Check for rate limit errors
+ if _is_rate_limit_error(error_str):
+ parsed = {
+ "error_type": "rate_limit",
+ "user_message": "You're making requests too quickly. Please wait a moment and try again, or switch to a different model.",
+ "technical_details": error_str,
+ "action_required": "wait_or_switch_model",
+ "original_error": error_str,
+ }
+ logger.debug("parse_api_error: classified as %s", parsed["error_type"])
+ return parsed
+
+ # Check for authentication/permission errors
+ if _is_auth_error(error_str):
+ return {
+ "error_type": "auth_error",
+ "user_message": "Authentication failed. Please check your credentials or API key configuration.",
+ "technical_details": error_str,
+ "action_required": "check_credentials",
+ "original_error": error_str,
+ }
+
+ # Check for network/timeout errors
+ if _is_network_error(error_str, error_type_name):
+ return {
+ "error_type": "network_error",
+ "user_message": "Network connection failed. Please check your internet connection and try again.",
+ "technical_details": error_str,
+ "action_required": "retry",
+ "original_error": error_str,
+ }
+
+ # Check for model not found errors
+ if _is_model_not_found_error(error_str):
+ return {
+ "error_type": "model_not_found",
+ "user_message": "The requested model is not available. Please switch to a different model.",
+ "technical_details": error_str,
+ "action_required": "switch_model",
+ "original_error": error_str,
+ }
+
+ # Check for Claude extended thinking temperature errors
+ if _is_claude_temperature_thinking_error(error_str):
+ parsed = {
+ "error_type": "claude_temperature_error",
+ "user_message": "Claude's extended thinking mode requires temperature to be set to 1.0. Please adjust your model settings or disable extended thinking.",
+ "technical_details": error_str,
+ "action_required": "adjust_temperature",
+ "original_error": error_str,
+ }
+ logger.debug("parse_api_error: classified as %s", parsed["error_type"])
+ return parsed
+
+ # Generic error fallback
+ parsed = {
+ "error_type": "unknown",
+ "user_message": f"An unexpected error occurred: {error_str}",
+ "technical_details": error_str,
+ "action_required": None,
+ "original_error": error_str,
+ }
+ logger.debug("parse_api_error: classified as %s", parsed["error_type"])
+ return parsed
+
+
+def _is_quota_exceeded_error(error_str: str) -> bool:
+ """Check if error is a quota exceeded error."""
+ quota_patterns = [
+ r"quota\s+exceeded",
+ r"HTTP\s+429",
+ r"aiplatform\.googleapis\.com/online_prediction_requests_per_base_model",
+ r"resource_exhausted",
+ r"too\s+many\s+requests.*quota",
+ ]
+
+ for pattern in quota_patterns:
+ if re.search(pattern, error_str, re.IGNORECASE):
+ return True
+ return False
+
+
+def _is_rate_limit_error(error_str: str) -> bool:
+ """Check if error is a rate limit error."""
+ rate_limit_patterns = [
+ r"rate\s+limit",
+ r"too\s+many\s+requests",
+ r"throttle",
+ r"requests.*per.*second",
+ r"requests.*per.*minute",
+ ]
+
+ for pattern in rate_limit_patterns:
+ if re.search(pattern, error_str, re.IGNORECASE):
+ # Don't double-match quota errors
+ if not _is_quota_exceeded_error(error_str):
+ return True
+ return False
+
+
+def _is_auth_error(error_str: str) -> bool:
+ """Check if error is an authentication error."""
+ auth_patterns = [
+ r"authentication\s+failed",
+ r"unauthorized",
+ r"HTTP\s+401",
+ r"HTTP\s+403",
+ r"permission\s+denied",
+ r"invalid.*api.*key",
+ r"invalid.*credentials",
+ r"access\s+denied",
+ ]
+
+ for pattern in auth_patterns:
+ if re.search(pattern, error_str, re.IGNORECASE):
+ return True
+ return False
+
+
+def _is_network_error(error_str: str, error_type_name: str) -> bool:
+ """Check if error is a network/connectivity error."""
+ # Check error type names
+ network_error_types = [
+ "TimeoutError",
+ "ConnectionError",
+ "ConnectTimeout",
+ "ReadTimeout",
+ "HTTPError",
+ ]
+
+ if any(err_type in error_type_name for err_type in network_error_types):
+ return True
+
+ # Check error message patterns
+ network_patterns = [
+ r"timeout",
+ r"connection\s+refused",
+ r"connection\s+reset",
+ r"connection\s+failed",
+ r"network\s+error",
+ r"unable\s+to\s+connect",
+ r"host\s+unreachable",
+ ]
+
+ for pattern in network_patterns:
+ if re.search(pattern, error_str, re.IGNORECASE):
+ return True
+ return False
+
+
+def _is_model_not_found_error(error_str: str) -> bool:
+ """Check if error is a model not found error."""
+ # Use simple patterns that match anywhere in the string
+ if re.search(r"model.*not\s+found", error_str, re.IGNORECASE):
+ return True
+ if re.search(r"invalid\s+model", error_str, re.IGNORECASE):
+ return True
+ if re.search(r"unknown\s+model", error_str, re.IGNORECASE):
+ return True
+ if re.search(r"model.*does\s+not\s+exist", error_str, re.IGNORECASE):
+ return True
+ if re.search(r"HTTP\s+404.*model", error_str, re.IGNORECASE):
+ return True
+
+ return False
+
+
+def _is_claude_temperature_thinking_error(error_str: str) -> bool:
+ """Check if error is Claude's temperature/thinking configuration error."""
+ return (
+ "temperature" in error_str.lower()
+ and "thinking" in error_str.lower()
+ and ("may only be set to 1" in error_str or "must be 1" in error_str)
+ )
+
+
+def _extract_quota_details(error_str: str) -> str:
+ """Extract specific quota details from error message."""
+ # Try to extract the specific quota metric if present
+ match = re.search(r"quota\s+exceeded\s+for\s+([\w\./]+)", error_str, re.IGNORECASE)
+ if match:
+ return f"Quota exceeded for: {match.group(1)}"
+
+ # Try to extract model name if present
+ match = re.search(r"base\s+model[:\s]+([\w\-]+)", error_str, re.IGNORECASE)
+ if match:
+ return f"Quota exceeded for base model: {match.group(1)}"
+
+ return error_str
diff --git a/code_puppy/api/main.py b/code_puppy/api/main.py
new file mode 100644
index 000000000..2153ae43b
--- /dev/null
+++ b/code_puppy/api/main.py
@@ -0,0 +1,97 @@
+"""Entry point for running the FastAPI server."""
+
+import logging
+import os
+import sys
+
+import uvicorn
+
+from code_puppy.api.app import create_app
+from code_puppy.logging_setup import configure_backend_logging
+
+
+def _resolve_log_level() -> tuple[int, str]:
+ """Resolve env log levels to (backend_logging_level_int, uvicorn_level_str)."""
+ raw = (
+ (os.getenv("BACKEND_LOG_LEVEL") or os.getenv("LOG_LEVEL") or "INFO")
+ .strip()
+ .upper()
+ )
+ level_int = getattr(logging, raw, logging.INFO)
+ # Uvicorn wants lower-case level names
+ uvicorn_level = (
+ raw.lower()
+ if raw.lower() in {"critical", "error", "warning", "info", "debug", "trace"}
+ else "info"
+ )
+ return level_int, uvicorn_level
+
+
+# Configure logging (honors BACKEND_LOG_* and LOG_LEVEL fallback)
+_level_int, _uvicorn_level = _resolve_log_level()
+try:
+ _level_int = configure_backend_logging(level=_level_int)
+except Exception as exc:
+ print(
+ f"[code_puppy.api.main] configure_backend_logging failed: {exc}",
+ file=sys.stderr,
+ )
+ _fallback_handler = logging.StreamHandler(sys.stdout)
+ _fallback_handler.setLevel(_level_int)
+ _fallback_handler.setFormatter(
+ logging.Formatter(
+ "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+ datefmt="%H:%M:%S",
+ )
+ )
+ logging.basicConfig(
+ level=_level_int,
+ handlers=[_fallback_handler],
+ force=True,
+ )
+
+for name in [
+ "code_puppy",
+ "code_puppy.api",
+ "code_puppy.api.websocket",
+ "uvicorn",
+ "uvicorn.error",
+ "uvicorn.access",
+ "httpx",
+]:
+ logging.getLogger(name).setLevel(_level_int)
+
+app = create_app()
+
+
+def main(host: str = "127.0.0.1", port: int = 8765) -> None:
+ """Run the FastAPI server.
+
+ Args:
+ host: The host address to bind to. Defaults to localhost.
+ port: The port number to listen on. Defaults to 8765.
+ """
+ # Force stdout to be unbuffered
+ sys.stdout.reconfigure(line_buffering=True)
+
+ if (os.getenv("DEBUG_IMPORTS") or "").strip() == "1":
+ import code_puppy
+ from code_puppy.agents import base_agent as _base_agent
+
+ print("\n=== DEBUG_IMPORTS=1 ===", flush=True)
+ print(f"code_puppy package: {code_puppy.__file__}", flush=True)
+ print(f"base_agent module: {_base_agent.__file__}", flush=True)
+ print("sys.path (first 10):", flush=True)
+ for p in sys.path[:10]:
+ print(f" - {p}", flush=True)
+ print("=======================\n", flush=True)
+
+ print(
+ f"🐶 Starting Code Puppy API server (LOG_LEVEL={logging.getLevelName(_level_int)})...",
+ flush=True,
+ )
+ uvicorn.run(app, host=host, port=port, log_level=_uvicorn_level)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/code_puppy/api/permission_plugin.py b/code_puppy/api/permission_plugin.py
new file mode 100644
index 000000000..411a4b799
--- /dev/null
+++ b/code_puppy/api/permission_plugin.py
@@ -0,0 +1,216 @@
+"""
+Permission Plugin for Tool Call Execution
+
+This plugin registers callbacks that intercept tool executions
+and request user permission via the WebSocket API.
+
+It handles:
+- All tool calls via pre_tool_call callback
+- Shell commands via run_shell_command callback (for backward compatibility)
+
+Permission is automatically granted in yolo mode.
+For WebSocket mode (CLI web interface), permission is requested via UI.
+For terminal mode, permission is bypassed to maintain backward compatibility.
+"""
+
+import logging
+from contextvars import ContextVar
+from typing import Any, Dict, Optional, Tuple
+
+logger = logging.getLogger(__name__)
+
+# Task-scoped WebSocket context — each asyncio task (i.e. each WS connection)
+# gets its own isolated value via Python's ContextVar mechanism.
+_ws_context: ContextVar[Optional[Tuple[Any, str]]] = ContextVar(
+ "ws_permission_context", default=None
+)
+
+# Task-scoped flag used by chat websocket streaming to suppress duplicate
+# tool_call/tool_result events from frontend_emitter pre/post_tool_call hooks.
+_suppress_emitter_tool_events: ContextVar[bool] = ContextVar(
+ "ws_suppress_emitter_tool_events", default=False
+)
+
+
+def set_websocket_context(websocket: Any, session_id: str) -> None:
+ """Set the current WebSocket context for permission requests (task-scoped)."""
+ _ws_context.set((websocket, session_id))
+ logger.debug("[Permission] WebSocket context set for session %s", session_id)
+
+
+def get_websocket_context() -> Optional[Tuple[Any, str]]:
+ """Get the current WebSocket context. Returns ``(websocket, session_id)`` or ``None``."""
+ return _ws_context.get()
+
+
+def clear_websocket_context() -> None:
+ """Clear the WebSocket context for the current task."""
+ _ws_context.set(None)
+ logger.debug("[Permission] WebSocket context cleared")
+
+
+def set_suppress_emitter_tool_events(suppress: bool) -> None:
+ """Enable/disable suppression of frontend_emitter tool lifecycle events."""
+ _suppress_emitter_tool_events.set(bool(suppress))
+
+
+def get_suppress_emitter_tool_events() -> bool:
+ """Return True when frontend_emitter tool_call_start/complete should be skipped."""
+ return _suppress_emitter_tool_events.get()
+
+
+async def pre_tool_call_permission(
+ tool_name: str, tool_args: dict, context: Any = None
+) -> Optional[Dict[str, Any]]:
+ """
+ Permission callback for all tool executions via pre_tool_call.
+
+ Args:
+ tool_name: Name of the tool being called
+ tool_args: Arguments for the tool
+ context: Execution context
+
+ Returns:
+ None to allow the tool, or a dict with error info to block it
+ """
+ # Check if we have a WebSocket context (CLI web mode)
+ ctx = get_websocket_context()
+ if ctx is None:
+ # Terminal mode or no WebSocket - allow tool
+ logger.debug("[Permission] No WebSocket context, allowing tool: %s", tool_name)
+ return None
+
+ ws, sid = ctx
+
+ # WebSocket mode - request permission
+ logger.info("[Permission] Requesting permission for tool: %s", tool_name)
+
+ try:
+ from code_puppy.api.permissions import request_permission
+
+ # Build human-readable description using formatters
+ from code_puppy.api.tool_formatters import format_tool_call
+
+ try:
+ formatted_args = format_tool_call(tool_name, tool_args)
+ description = formatted_args
+ except Exception as e:
+ # Fallback to raw args if formatting fails
+ logger.warning("[Permission] Failed to format tool args: %s", e)
+ description = f"Tool: {tool_name}"
+ if tool_args:
+ arg_preview = str(tool_args)[:200]
+ if len(str(tool_args)) > 200:
+ arg_preview += "..."
+ description = f"{description}\nArguments: {arg_preview}"
+
+ approved = await request_permission(
+ websocket=ws,
+ session_id=sid,
+ request_type="tool_call",
+ title=f"Execute Tool: {tool_name}",
+ description=description,
+ details={
+ "tool_name": tool_name,
+ "tool_args": tool_args,
+ },
+ timeout=300, # 5 minute timeout
+ )
+
+ if not approved:
+ logger.info("[Permission] ❌ Tool DENIED: %s", tool_name)
+ # Return error dict to block the tool execution
+ return {
+ "error": "Permission denied by user",
+ "blocked": True,
+ "tool_name": tool_name,
+ }
+
+ logger.info("[Permission] Tool APPROVED: %s", tool_name)
+ return None # Allow the tool
+
+ except Exception as e:
+ logger.error("[Permission] Error requesting permission: %s", e)
+ # On error, allow the tool (fail open for now)
+ return None
+
+
+async def shell_command_permission(
+ context: Any, command: str, cwd: str, timeout: int
+) -> Optional[Dict[str, Any]]:
+ """
+ Permission callback for shell command execution (legacy/backward compatibility).
+
+ This is kept for backward compatibility but the main permission handling
+ now happens via pre_tool_call_permission.
+
+ Args:
+ context: The execution context
+ command: The shell command to execute
+ cwd: Working directory
+ timeout: Command timeout
+
+ Returns:
+ None to allow the command, or a dict with 'blocked': True to deny
+ """
+ # Check if we have a WebSocket context (CLI web mode)
+ ctx = get_websocket_context()
+ if ctx is None:
+ # Terminal mode or no WebSocket - allow command
+ logger.debug("[Permission] No WebSocket context, allowing command: %s", command)
+ return None
+
+ ws, sid = ctx
+
+ # WebSocket mode - request permission
+ logger.info("[Permission] Requesting permission for shell command: %s", command)
+
+ try:
+ from code_puppy.api.permissions import request_permission
+
+ approved = await request_permission(
+ websocket=ws,
+ session_id=sid,
+ request_type="shell_command",
+ title="Execute Shell Command",
+ description=f"Run: {command}",
+ details={
+ "command": command,
+ "cwd": cwd or "current directory",
+ "timeout": timeout,
+ },
+ timeout=300, # 5 minute timeout
+ )
+
+ if not approved:
+ logger.info("[Permission] Command DENIED: %s", command)
+ return {
+ "blocked": True,
+ "error": "Permission denied by user",
+ "reasoning": f"User denied execution of command: {command}",
+ }
+
+ logger.info("[Permission] Command APPROVED: %s", command)
+ return None # Allow the command
+
+ except Exception as e:
+ logger.error("[Permission] Error requesting permission for command: %s", e)
+ # On error, allow the command (fail open for now)
+ return None
+
+
+def register_permission_callbacks():
+ """Register the permission callbacks with code-puppy."""
+ from code_puppy.callbacks import register_callback
+
+ # Register for all tool calls
+ register_callback("pre_tool_call", pre_tool_call_permission)
+ logger.info("[Permission Plugin] Registered pre_tool_call permission callback")
+
+ # Keep shell command callback for backward compatibility
+ register_callback("run_shell_command", shell_command_permission)
+ logger.info("[Permission Plugin] Registered shell command permission callback")
+
+
+# Auto-register when imported
+register_permission_callbacks()
diff --git a/code_puppy/api/permissions.py b/code_puppy/api/permissions.py
new file mode 100644
index 000000000..c820693dc
--- /dev/null
+++ b/code_puppy/api/permissions.py
@@ -0,0 +1,172 @@
+"""
+Permission System for Tool Call Execution
+
+This module provides a permission request system that integrates with the WebSocket API
+to request user approval before executing potentially dangerous operations.
+
+Features:
+- WebSocket-based permission requests with in-chat action buttons
+- 5-minute timeout on permission waits
+- Automatic approval in yolo mode
+- CLI mode support via WebSocket
+
+Usage:
+ from code_puppy.api.permissions import request_permission
+
+ approved = await request_permission(
+ websocket=ws,
+ session_id="session-123",
+ request_type="shell_command",
+ title="Execute Shell Command",
+ description="Run: ls -la",
+ details={"command": "ls -la", "cwd": "/home/user"}
+ )
+"""
+
+import asyncio
+import logging
+import uuid
+from typing import Any, Dict, Optional
+
+from fastapi import WebSocket
+
+from code_puppy.api.ws.schemas import ServerPermissionRequest
+
+logger = logging.getLogger(__name__)
+
+# Global dictionary to track pending permission requests
+# This is managed by the WebSocket API module
+permission_futures: Dict[str, asyncio.Future] = {}
+
+
+async def request_permission(
+ websocket: Optional[WebSocket],
+ session_id: str,
+ request_type: str,
+ title: str,
+ description: str,
+ details: Dict[str, Any],
+ timeout: int = 300,
+) -> bool:
+ """
+ Request permission from user via WebSocket.
+
+ Args:
+ websocket: WebSocket connection to send request through
+ session_id: Current session ID
+ request_type: Type of permission (e.g., 'shell_command')
+ title: Permission dialog title
+ description: Human-readable description
+ details: Additional context (command, args, etc.)
+ timeout: Timeout in seconds (default 5 minutes)
+
+ Returns:
+ bool: True if approved, False if denied or timeout
+ """
+ # Check if yolo mode is enabled (auto-approve everything)
+ try:
+ import json
+ import os
+
+ from code_puppy.config import CONFIG_FILE
+
+ if os.path.exists(CONFIG_FILE):
+ try:
+ with open(CONFIG_FILE, "r") as f:
+ config = json.load(f)
+ yolo_mode = str(config.get("yolo_mode", "false")).lower() == "true"
+ if yolo_mode:
+ logger.info(
+ f"[Permission] YOLO mode enabled, auto-approving {request_type}"
+ )
+ return True
+ except Exception:
+ pass # If config read fails, continue to request permission
+ except Exception:
+ pass # If we can't check, require permission
+
+ # If no WebSocket connection, deny by default (fail-safe)
+ if websocket is None:
+ logger.warning("[Permission] No WebSocket connection, denying %s", request_type)
+ return False
+
+ request_id = str(uuid.uuid4())
+
+ # Send permission request to frontend
+ try:
+ await websocket.send_json(
+ ServerPermissionRequest(
+ request_id=request_id,
+ permission_type=request_type,
+ title=title,
+ description=description,
+ details=details,
+ session_id=session_id,
+ timeout_seconds=timeout,
+ ).model_dump(exclude_none=True)
+ )
+ logger.info("[Permission] Sent request %s for %s", request_id, request_type)
+ except Exception as e:
+ logger.error("[Permission] Failed to send request: %s", e)
+ return False
+
+ # Create a future to wait for the response
+ future = asyncio.Future()
+ permission_futures[request_id] = future
+
+ try:
+ # Wait for user response with timeout
+ approved = await asyncio.wait_for(future, timeout=timeout)
+ logger.info(
+ f"[Permission] ✅ Got response for {request_id}: {'APPROVED' if approved else 'DENIED'}"
+ )
+ return approved
+ except asyncio.TimeoutError:
+ logger.warning(
+ "[Permission] Request %s timed out after %ss", request_id, timeout
+ )
+ return False
+ except Exception as e:
+ logger.error("[Permission] Error waiting for permission: %s", e)
+ return False
+ finally:
+ # Clean up the future
+ permission_futures.pop(request_id, None)
+
+
+def handle_permission_response(request_id: str, approved: bool) -> bool:
+ """
+ Handle a permission response from the client.
+
+ Args:
+ request_id: The request ID from the permission_request
+ approved: True if approved, False if denied
+
+ Returns:
+ bool: True if the response was handled, False if request_id not found
+ """
+ logger.info(
+ f"[Permission] handle_permission_response called: request_id={request_id}, approved={approved}"
+ )
+ logger.info(
+ f"[Permission] Current permission_futures keys: {list(permission_futures.keys())}"
+ )
+
+ future = permission_futures.get(request_id)
+ logger.info(
+ f"[Permission] Future found: {future is not None}, done: {future.done() if future else 'N/A'}"
+ )
+
+ if future and not future.done():
+ logger.info("[Permission] Setting future result to: %s", approved)
+ future.set_result(approved)
+ logger.info("[Permission] Handled response for %s: %s", request_id, approved)
+ return True
+ else:
+ logger.warning(
+ f"[Permission] Received response for unknown/expired request: {request_id}"
+ )
+ logger.warning(
+ f"[Permission] Future exists: {future is not None}, Future done: {future.done() if future else 'N/A'}"
+ )
+ return False
diff --git a/code_puppy/api/pty_manager.py b/code_puppy/api/pty_manager.py
new file mode 100644
index 000000000..d57dacdc8
--- /dev/null
+++ b/code_puppy/api/pty_manager.py
@@ -0,0 +1,453 @@
+"""PTY Manager for terminal emulation with cross-platform support.
+
+Provides pseudo-terminal (PTY) functionality for interactive shell sessions
+via WebSocket connections. Supports Unix (pty module) and Windows (pywinpty).
+"""
+
+import asyncio
+import logging
+import os
+import signal
+import struct
+import sys
+from dataclasses import dataclass, field
+from typing import Any, Callable, Optional
+
+logger = logging.getLogger(__name__)
+
+# Platform detection
+IS_WINDOWS = sys.platform == "win32"
+
+# Conditional imports based on platform
+if IS_WINDOWS:
+ try:
+ import winpty # type: ignore
+
+ HAS_WINPTY = True
+ except ImportError:
+ HAS_WINPTY = False
+ winpty = None
+else:
+ import fcntl
+ import pty
+ import termios
+
+ HAS_WINPTY = False
+
+
+@dataclass
+class PTYSession:
+ """Represents an active PTY session."""
+
+ session_id: str
+ master_fd: Optional[int] = None # Unix only
+ slave_fd: Optional[int] = None # Unix only
+ pid: Optional[int] = None # Unix only
+ winpty_process: Any = None # Windows only
+ cols: int = 80
+ rows: int = 24
+ on_output: Optional[Callable[[bytes], None]] = None
+ _reader_task: Optional[asyncio.Task] = None # type: ignore
+ _running: bool = field(default=False, init=False)
+
+ def is_alive(self) -> bool:
+ """Check if the PTY session is still active."""
+ if IS_WINDOWS:
+ return self.winpty_process is not None and self.winpty_process.isalive()
+ else:
+ if self.pid is None:
+ return False
+ try:
+ pid_result, status = os.waitpid(self.pid, os.WNOHANG)
+ if pid_result == 0:
+ return True # Still running
+ return False # Exited
+ except ChildProcessError:
+ return False # Already reaped
+
+
+class PTYManager:
+ """Manages PTY sessions for terminal emulation.
+
+ Provides cross-platform terminal emulation with support for:
+ - Unix systems via the pty module
+ - Windows via pywinpty (optional dependency)
+
+ Example:
+ manager = PTYManager()
+ session = await manager.create_session(
+ session_id="my-terminal",
+ on_output=lambda data: print(data.decode())
+ )
+ await manager.write(session.session_id, b"ls -la\n")
+ await manager.close_session(session.session_id)
+ """
+
+ def __init__(self) -> None:
+ self._sessions: dict[str, PTYSession] = {}
+ self._lock = asyncio.Lock()
+
+ @property
+ def sessions(self) -> dict[str, PTYSession]:
+ """Get all active sessions."""
+ return self._sessions.copy()
+
+ async def create_session(
+ self,
+ session_id: str,
+ cols: int = 80,
+ rows: int = 24,
+ on_output: Optional[Callable[[bytes], None]] = None,
+ shell: Optional[str] = None,
+ ) -> PTYSession:
+ """Create a new PTY session.
+
+ Args:
+ session_id: Unique identifier for the session
+ cols: Terminal width in columns
+ rows: Terminal height in rows
+ on_output: Callback for terminal output
+ shell: Shell to spawn (defaults to user's shell or /bin/bash)
+
+ Returns:
+ PTYSession: The created session
+
+ Raises:
+ RuntimeError: If session creation fails
+ """
+ async with self._lock:
+ if session_id in self._sessions:
+ logger.warning(f"Session {session_id} already exists, closing old one")
+ await self._close_session_internal(session_id)
+
+ if IS_WINDOWS:
+ session = await self._create_windows_session(
+ session_id, cols, rows, on_output, shell
+ )
+ else:
+ session = await self._create_unix_session(
+ session_id, cols, rows, on_output, shell
+ )
+
+ self._sessions[session_id] = session
+ logger.info(f"Created PTY session: {session_id}")
+ return session
+
+ async def _create_unix_session(
+ self,
+ session_id: str,
+ cols: int,
+ rows: int,
+ on_output: Optional[Callable[[bytes], None]],
+ shell: Optional[str],
+ ) -> PTYSession:
+ """Create a PTY session on Unix systems."""
+ shell = shell or os.environ.get("SHELL", "/bin/bash")
+
+ # Fork a new process with a PTY
+ pid, master_fd = pty.fork()
+
+ if pid == 0:
+ # Child process - exec the shell
+ os.execlp(shell, shell, "-i") # noqa: S606
+ else:
+ # Parent process
+ # Set terminal size
+ self._set_unix_winsize(master_fd, rows, cols)
+
+ # Make master_fd non-blocking
+ flags = fcntl.fcntl(master_fd, fcntl.F_GETFL)
+ fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
+
+ session = PTYSession(
+ session_id=session_id,
+ master_fd=master_fd,
+ pid=pid,
+ cols=cols,
+ rows=rows,
+ on_output=on_output,
+ )
+ session._running = True
+
+ # Start reader task
+ session._reader_task = asyncio.create_task(self._unix_reader_loop(session))
+
+ return session
+
+ async def _create_windows_session(
+ self,
+ session_id: str,
+ cols: int,
+ rows: int,
+ on_output: Optional[Callable[[bytes], None]],
+ shell: Optional[str],
+ ) -> PTYSession:
+ """Create a PTY session on Windows systems."""
+ if not HAS_WINPTY:
+ raise RuntimeError(
+ "pywinpty is required for Windows terminal support. "
+ "Install it with: pip install pywinpty"
+ )
+
+ shell = shell or os.environ.get("COMSPEC", "cmd.exe")
+
+ # Create winpty process
+ winpty_process = winpty.PtyProcess.spawn(
+ shell,
+ dimensions=(rows, cols),
+ )
+
+ session = PTYSession(
+ session_id=session_id,
+ winpty_process=winpty_process,
+ cols=cols,
+ rows=rows,
+ on_output=on_output,
+ )
+ session._running = True
+
+ # Start reader task
+ session._reader_task = asyncio.create_task(self._windows_reader_loop(session))
+
+ return session
+
+ def _set_unix_winsize(self, fd: int, rows: int, cols: int) -> None:
+ """Set the terminal window size on Unix."""
+ winsize = struct.pack("HHHH", rows, cols, 0, 0)
+ fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
+
+ async def _unix_reader_loop(self, session: PTYSession) -> None:
+ """Read output from Unix PTY and forward to callback."""
+ loop = asyncio.get_running_loop()
+
+ try:
+ while session._running and session.master_fd is not None:
+ try:
+ data = await loop.run_in_executor(
+ None, self._read_unix_pty, session.master_fd
+ )
+
+ if data is None:
+ # No data available, wait a bit
+ await asyncio.sleep(0.01)
+ continue
+ elif data == b"":
+ # EOF - process terminated
+ break
+ elif session.on_output:
+ session.on_output(data)
+
+ except asyncio.CancelledError:
+ break
+
+ except Exception as e:
+ logger.error(f"Unix reader loop error: {e}")
+ finally:
+ session._running = False
+
+ def _read_unix_pty(self, fd: int) -> bytes | None:
+ """Read from Unix PTY file descriptor.
+
+ Returns:
+ bytes: Data read from PTY
+ None: No data available (would block)
+ b'': EOF (process terminated)
+ """
+ try:
+ data = os.read(fd, 4096)
+ return data
+ except BlockingIOError:
+ return None
+ except OSError:
+ return b""
+
+ async def _windows_reader_loop(self, session: PTYSession) -> None:
+ """Read output from Windows PTY and forward to callback."""
+ loop = asyncio.get_running_loop()
+
+ try:
+ while (
+ session._running
+ and session.winpty_process is not None
+ and session.winpty_process.isalive()
+ ):
+ try:
+ data = await loop.run_in_executor(
+ None, session.winpty_process.read, 4096
+ )
+ if data and session.on_output:
+ session.on_output(
+ data.encode() if isinstance(data, str) else data
+ )
+ except EOFError:
+ break
+ except asyncio.CancelledError:
+ break
+
+ await asyncio.sleep(0.01)
+
+ except Exception as e:
+ logger.error(f"Windows reader loop error: {e}")
+ finally:
+ session._running = False
+
+ async def write(self, session_id: str, data: bytes) -> bool:
+ """Write data to a PTY session.
+
+ Args:
+ session_id: The session to write to
+ data: Data to write
+
+ Returns:
+ bool: True if write succeeded
+ """
+ session = self._sessions.get(session_id)
+ if not session:
+ logger.warning(f"Session {session_id} not found")
+ return False
+
+ try:
+ if IS_WINDOWS:
+ if session.winpty_process:
+ session.winpty_process.write(
+ data.decode() if isinstance(data, bytes) else data
+ )
+ return True
+ else:
+ if session.master_fd is not None:
+ os.write(session.master_fd, data)
+ return True
+ except Exception as e:
+ logger.error(f"Write error for session {session_id}: {e}")
+
+ return False
+
+ async def resize(self, session_id: str, cols: int, rows: int) -> bool:
+ """Resize a PTY session.
+
+ Args:
+ session_id: The session to resize
+ cols: New width in columns
+ rows: New height in rows
+
+ Returns:
+ bool: True if resize succeeded
+ """
+ session = self._sessions.get(session_id)
+ if not session:
+ logger.warning(f"Session {session_id} not found")
+ return False
+
+ try:
+ if IS_WINDOWS:
+ if session.winpty_process:
+ session.winpty_process.setwinsize(rows, cols)
+ else:
+ if session.master_fd is not None:
+ self._set_unix_winsize(session.master_fd, rows, cols)
+
+ session.cols = cols
+ session.rows = rows
+ logger.debug(f"Resized session {session_id} to {cols}x{rows}")
+ return True
+
+ except Exception as e:
+ logger.error(f"Resize error for session {session_id}: {e}")
+ return False
+
+ async def close_session(self, session_id: str) -> bool:
+ """Close a PTY session.
+
+ Args:
+ session_id: The session to close
+
+ Returns:
+ bool: True if session was closed
+ """
+ async with self._lock:
+ return await self._close_session_internal(session_id)
+
+ async def _close_session_internal(self, session_id: str) -> bool:
+ """Internal session close without lock."""
+ session = self._sessions.pop(session_id, None)
+ if not session:
+ return False
+
+ session._running = False
+
+ # Cancel reader task
+ if session._reader_task:
+ session._reader_task.cancel()
+ try:
+ await session._reader_task
+ except asyncio.CancelledError:
+ pass
+
+ # Clean up platform-specific resources
+ if IS_WINDOWS:
+ if session.winpty_process:
+ try:
+ session.winpty_process.terminate()
+ except Exception as e:
+ logger.debug(f"Error terminating winpty: {e}")
+ else:
+ # Close file descriptors
+ if session.master_fd is not None:
+ try:
+ os.close(session.master_fd)
+ except OSError:
+ pass
+
+ # Terminate child process
+ if session.pid is not None:
+ try:
+ os.kill(session.pid, signal.SIGTERM)
+ # Use WNOHANG to avoid blocking the event loop
+ try:
+ os.waitpid(session.pid, os.WNOHANG)
+ except ChildProcessError:
+ pass
+ except (OSError, ChildProcessError):
+ pass
+
+ logger.info(f"Closed PTY session: {session_id}")
+ return True
+
+ async def close_all(self) -> None:
+ """Close all PTY sessions."""
+ async with self._lock:
+ session_ids = list(self._sessions.keys())
+ for session_id in session_ids:
+ await self._close_session_internal(session_id)
+ logger.info("Closed all PTY sessions")
+
+ def get_session(self, session_id: str) -> Optional[PTYSession]:
+ """Get a session by ID.
+
+ Args:
+ session_id: The session ID
+
+ Returns:
+ PTYSession or None if not found
+ """
+ return self._sessions.get(session_id)
+
+ def list_sessions(self) -> list[str]:
+ """List all active session IDs.
+
+ Returns:
+ List of session IDs
+ """
+ return list(self._sessions.keys())
+
+
+# Global PTY manager instance
+_pty_manager: Optional[PTYManager] = None
+
+
+def get_pty_manager() -> PTYManager:
+ """Get or create the global PTY manager instance."""
+ global _pty_manager
+ if _pty_manager is None:
+ _pty_manager = PTYManager()
+ return _pty_manager
diff --git a/code_puppy/api/routers/__init__.py b/code_puppy/api/routers/__init__.py
new file mode 100644
index 000000000..00b53ba6d
--- /dev/null
+++ b/code_puppy/api/routers/__init__.py
@@ -0,0 +1,12 @@
+"""API routers for Code Puppy REST endpoints.
+
+This package contains the FastAPI router modules for different API domains:
+ - config: Configuration management endpoints
+ - commands: Command execution endpoints
+ - sessions: Session management endpoints
+ - agents: Agent-related endpoints
+"""
+
+from code_puppy.api.routers import agents, commands, config, protocol, sessions
+
+__all__ = ["config", "commands", "sessions", "agents", "protocol"]
diff --git a/code_puppy/api/routers/agents.py b/code_puppy/api/routers/agents.py
new file mode 100644
index 000000000..faf975650
--- /dev/null
+++ b/code_puppy/api/routers/agents.py
@@ -0,0 +1,116 @@
+"""Agents API endpoints for agent management.
+
+This router provides REST endpoints for:
+- Listing all available agents with their metadata
+- Refreshing the agent registry to discover new agents
+- Switching the current active agent
+"""
+
+from typing import Any, Dict, List
+
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel
+
+router = APIRouter()
+
+
+@router.get("/")
+async def list_agents() -> List[Dict[str, Any]]:
+ """List all available agents.
+
+ Returns a list of all agents registered in the system,
+ including their name, display name, and description.
+
+ Returns:
+ List[Dict[str, Any]]: List of agent information dictionaries.
+ """
+ from code_puppy.agents import get_agent_descriptions, get_available_agents
+
+ agents_dict = get_available_agents()
+ descriptions = get_agent_descriptions()
+
+ return [
+ {
+ "name": name,
+ "display_name": display_name,
+ "description": descriptions.get(name, "No description"),
+ }
+ for name, display_name in agents_dict.items()
+ ]
+
+
+@router.post("/refresh")
+async def refresh_agents_endpoint() -> Dict[str, Any]:
+ """Force refresh the agents list by re-running agent discovery.
+
+ This endpoint triggers the agent manager to re-scan for:
+ - Python agent classes in the agents package
+ - JSON agent configuration files in user directory
+ - Plugin-registered agents
+
+ Returns:
+ Dict[str, Any]: Result containing:
+ - success (bool): True if refresh completed
+ - count (int): Number of agents discovered
+ - message (str): Status message
+ """
+ from code_puppy.agents import get_available_agents
+ from code_puppy.agents import refresh_agents as do_refresh_agents
+
+ # Refresh agents - this will call _discover_agents() again
+ do_refresh_agents()
+
+ # Get the fresh count
+ agents = get_available_agents()
+
+ return {
+ "success": True,
+ "count": len(agents),
+ "message": "Agent discovery refreshed",
+ }
+
+
+class SwitchAgentRequest(BaseModel):
+ """Request model for switching agents."""
+
+ agent_name: str
+
+
+@router.post("/switch")
+async def switch_agent(request: SwitchAgentRequest) -> Dict[str, Any]:
+ """Switch to a different agent.
+
+ Args:
+ request: SwitchAgentRequest containing:
+ - agent_name (str): The name of the agent to switch to
+
+ Returns:
+ Dict[str, Any]: Result containing:
+ - success (bool): True if switch was successful
+ - agentName (str): The name of the agent switched to
+ - message (str): Success message
+
+ Raises:
+ HTTPException: If agent not found (404) or switch fails (500)
+ """
+ from code_puppy.agents import get_current_agent, set_current_agent
+
+ agent_name = request.agent_name
+
+ if not agent_name:
+ raise HTTPException(status_code=400, detail="agent_name is required")
+
+ # Switch to the new agent
+ success = set_current_agent(agent_name)
+
+ if not success:
+ raise HTTPException(status_code=404, detail=f"Agent '{agent_name}' not found")
+
+ # Get the new current agent for display name
+ current = get_current_agent()
+
+ return {
+ "success": True,
+ "agentName": current.name,
+ "message": f"Switched to {current.display_name}",
+ }
diff --git a/code_puppy/api/routers/commands.py b/code_puppy/api/routers/commands.py
new file mode 100644
index 000000000..e0e8892da
--- /dev/null
+++ b/code_puppy/api/routers/commands.py
@@ -0,0 +1,217 @@
+"""Commands API endpoints for slash command execution and autocomplete.
+
+This router provides REST endpoints for:
+- Listing all available slash commands
+- Getting info about specific commands
+- Executing slash commands
+- Autocomplete suggestions for partial commands
+"""
+
+import asyncio
+from concurrent.futures import ThreadPoolExecutor
+from typing import Any, List, Optional
+
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel
+
+# Thread pool for blocking command execution
+_executor = ThreadPoolExecutor(max_workers=4)
+
+# Timeout for command execution (seconds)
+COMMAND_TIMEOUT = 30.0
+
+router = APIRouter()
+
+
+# =============================================================================
+# Pydantic Models
+# =============================================================================
+
+
+class CommandInfo(BaseModel):
+ """Information about a registered command."""
+
+ name: str
+ description: str
+ usage: str
+ aliases: List[str] = []
+ category: str = "core"
+ detailed_help: Optional[str] = None
+
+
+class CommandExecuteRequest(BaseModel):
+ """Request to execute a slash command."""
+
+ command: str # Full command string, e.g., "/set model=gpt-4o"
+
+
+class CommandExecuteResponse(BaseModel):
+ """Response from executing a slash command."""
+
+ success: bool
+ result: Any = None
+ error: Optional[str] = None
+
+
+class AutocompleteRequest(BaseModel):
+ """Request for command autocomplete."""
+
+ partial: str # Partial command string, e.g., "/se" or "/set mo"
+
+
+class AutocompleteResponse(BaseModel):
+ """Response with autocomplete suggestions."""
+
+ suggestions: List[str]
+
+
+# =============================================================================
+# Endpoints
+# =============================================================================
+
+
+@router.get("/")
+async def list_commands() -> List[CommandInfo]:
+ """List all available slash commands.
+
+ Returns a sorted list of all unique commands (no alias duplicates),
+ with their metadata including name, description, usage, aliases,
+ category, and detailed help.
+
+ Returns:
+ List[CommandInfo]: Sorted list of command information.
+ """
+ from code_puppy.command_line.command_registry import get_unique_commands
+
+ commands = []
+ for cmd in get_unique_commands():
+ commands.append(
+ CommandInfo(
+ name=cmd.name,
+ description=cmd.description,
+ usage=cmd.usage,
+ aliases=cmd.aliases,
+ category=cmd.category,
+ detailed_help=cmd.detailed_help,
+ )
+ )
+ return sorted(commands, key=lambda c: c.name)
+
+
+@router.get("/{name}")
+async def get_command_info(name: str) -> CommandInfo:
+ """Get detailed info about a specific command.
+
+ Looks up a command by name or alias (case-insensitive).
+
+ Args:
+ name: Command name or alias (without leading /).
+
+ Returns:
+ CommandInfo: Full command information.
+
+ Raises:
+ HTTPException: 404 if command not found.
+ """
+ from code_puppy.command_line.command_registry import get_command
+
+ cmd = get_command(name)
+ if not cmd:
+ raise HTTPException(404, f"Command '/{name}' not found")
+
+ return CommandInfo(
+ name=cmd.name,
+ description=cmd.description,
+ usage=cmd.usage,
+ aliases=cmd.aliases,
+ category=cmd.category,
+ detailed_help=cmd.detailed_help,
+ )
+
+
+@router.post("/execute")
+async def execute_command(request: CommandExecuteRequest) -> CommandExecuteResponse:
+ """Execute a slash command.
+
+ Takes a command string (with or without leading /) and executes it
+ using the command handler. Runs in a thread pool to avoid blocking
+ the event loop, with a timeout to prevent hangs.
+
+ Args:
+ request: CommandExecuteRequest with the command to execute.
+
+ Returns:
+ CommandExecuteResponse: Result of command execution.
+ """
+ from code_puppy.command_line.command_handler import handle_command
+
+ command = request.command
+ if not command.startswith("/"):
+ command = "/" + command
+
+ loop = asyncio.get_running_loop()
+
+ try:
+ # Run blocking command in thread pool with timeout
+ result = await asyncio.wait_for(
+ loop.run_in_executor(_executor, handle_command, command),
+ timeout=COMMAND_TIMEOUT,
+ )
+ return CommandExecuteResponse(success=True, result=result)
+ except asyncio.TimeoutError:
+ return CommandExecuteResponse(
+ success=False, error=f"Command timed out after {COMMAND_TIMEOUT}s"
+ )
+ except Exception as e:
+ return CommandExecuteResponse(success=False, error=str(e))
+
+
+@router.post("/autocomplete")
+async def autocomplete_command(request: AutocompleteRequest) -> AutocompleteResponse:
+ """Get autocomplete suggestions for a partial command.
+
+ Provides intelligent autocomplete based on partial input:
+ - Empty input: returns all command names
+ - Partial command name: returns matching commands and aliases
+ - Complete command with args: returns usage hint
+
+ Args:
+ request: AutocompleteRequest with partial command string.
+
+ Returns:
+ AutocompleteResponse: List of autocomplete suggestions.
+ """
+ from code_puppy.command_line.command_registry import (
+ get_command,
+ get_unique_commands,
+ )
+
+ partial = request.partial.lstrip("/")
+
+ # If empty, return all command names
+ if not partial:
+ suggestions = [f"/{cmd.name}" for cmd in get_unique_commands()]
+ return AutocompleteResponse(suggestions=sorted(suggestions))
+
+ # Split into command name and args
+ parts = partial.split(maxsplit=1)
+ cmd_partial = parts[0].lower()
+
+ # If just the command name (no space yet), suggest matching commands
+ if len(parts) == 1:
+ suggestions = []
+ for cmd in get_unique_commands():
+ if cmd.name.startswith(cmd_partial):
+ suggestions.append(f"/{cmd.name}")
+ for alias in cmd.aliases:
+ if alias.startswith(cmd_partial):
+ suggestions.append(f"/{alias}")
+ return AutocompleteResponse(suggestions=sorted(set(suggestions)))
+
+ # Command name complete, suggest based on command type
+ # (For now, just return the command usage as a hint)
+ cmd = get_command(cmd_partial)
+ if cmd:
+ return AutocompleteResponse(suggestions=[cmd.usage])
+
+ return AutocompleteResponse(suggestions=[])
diff --git a/code_puppy/api/routers/models.py b/code_puppy/api/routers/models.py
new file mode 100644
index 000000000..c78d478cb
--- /dev/null
+++ b/code_puppy/api/routers/models.py
@@ -0,0 +1,23 @@
+"""Models API endpoint for listing available models."""
+
+from typing import Any, Dict, List
+
+from fastapi import APIRouter
+
+router = APIRouter()
+
+
+@router.get("/")
+async def list_models() -> List[Dict[str, Any]]:
+ """List all available model names from models.json config.
+
+ Returns:
+ List of dicts with 'name' key for each model.
+ """
+ from code_puppy.model_factory import ModelFactory
+
+ try:
+ config = ModelFactory.load_config()
+ return [{"name": name} for name in sorted(config.keys())]
+ except Exception:
+ return []
diff --git a/code_puppy/api/routers/protocol.py b/code_puppy/api/routers/protocol.py
new file mode 100644
index 000000000..e5b513559
--- /dev/null
+++ b/code_puppy/api/routers/protocol.py
@@ -0,0 +1,26 @@
+"""Protocol schema endpoint – exposes JSON Schema for the WebSocket protocol."""
+
+from fastapi import APIRouter
+from pydantic import TypeAdapter
+
+from code_puppy.api.ws.schemas import (
+ PROTOCOL_VERSION,
+ ClientMessage,
+ ServerMessage,
+)
+
+router = APIRouter()
+
+# Cache the schemas since they don't change at runtime
+_client_schema = TypeAdapter(ClientMessage).json_schema()
+_server_schema = TypeAdapter(ServerMessage).json_schema()
+
+
+@router.get("/schema")
+async def get_protocol_schema():
+ """Return the WebSocket protocol JSON Schema for client consumption."""
+ return {
+ "protocol_version": PROTOCOL_VERSION,
+ "client_messages": _client_schema,
+ "server_messages": _server_schema,
+ }
diff --git a/code_puppy/api/routers/sessions.py b/code_puppy/api/routers/sessions.py
new file mode 100644
index 000000000..bf046764c
--- /dev/null
+++ b/code_puppy/api/routers/sessions.py
@@ -0,0 +1,274 @@
+"""Sessions API endpoints for retrieving subagent session data."""
+
+import asyncio
+import json
+import pickle
+from concurrent.futures import ThreadPoolExecutor
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel
+
+# Thread pool for blocking file I/O
+_executor = ThreadPoolExecutor(max_workers=2)
+
+# Timeout for file operations (seconds)
+FILE_IO_TIMEOUT = 10.0
+
+router = APIRouter()
+
+
+class SessionInfo(BaseModel):
+ """Session metadata information."""
+
+ session_id: str
+ agent_name: Optional[str] = None
+ initial_prompt: Optional[str] = None
+ created_at: Optional[str] = None
+ last_updated: Optional[str] = None
+ message_count: int = 0
+
+
+class MessageContent(BaseModel):
+ """Message content with role and optional timestamp."""
+
+ role: str
+ content: Any
+ timestamp: Optional[str] = None
+
+
+class SessionDetail(SessionInfo):
+ """Session info with full message history."""
+
+ messages: List[Dict[str, Any]] = []
+
+
+def _get_sessions_dir() -> Path:
+ """Get the subagent sessions directory.
+
+ Returns:
+ Path to the subagent sessions directory
+ """
+ from code_puppy.config import DATA_DIR
+
+ return Path(DATA_DIR) / "subagent_sessions"
+
+
+def _serialize_message(msg: Any) -> Dict[str, Any]:
+ """Serialize a pydantic-ai message to a JSON-safe dict.
+
+ Handles both wrapped and unwrapped message formats:
+ - Wrapped (WS sessions): {'msg': , 'agent': ..., 'model': ..., 'ts': ...}
+ - Unwrapped: or
+
+ Preserves all message parts including ThinkingPart, ToolCallPart, TextPart, etc.
+
+ Args:
+ msg: A pydantic-ai message object or wrapped message dict
+
+ Returns:
+ JSON-serializable dictionary representation of the message
+ """
+
+ def _serialize_obj(obj: Any) -> Any:
+ """Recursively serialize an object to JSON-safe types."""
+ # Pydantic v2 models with model_dump
+ if hasattr(obj, "model_dump"):
+ return obj.model_dump(mode="json")
+
+ # Handle dicts recursively
+ if isinstance(obj, dict):
+ return {k: _serialize_obj(v) for k, v in obj.items()}
+
+ # Handle lists/tuples recursively
+ if isinstance(obj, (list, tuple)):
+ return [_serialize_obj(item) for item in obj]
+
+ # JSON-safe primitives
+ if isinstance(obj, (str, int, float, bool, type(None))):
+ return obj
+
+ # pydantic-ai message objects (ModelRequest, ModelResponse, Part subclasses)
+ # They have __dict__ but are not Pydantic models
+ if hasattr(obj, "__dict__"):
+ result = {}
+ for k, v in obj.__dict__.items():
+ result[k] = _serialize_obj(v)
+ return result
+
+ # Fallback: convert to string
+ return str(obj)
+
+ # Handle wrapped message format (used in WS sessions)
+ # {'msg': , 'agent': ..., 'model': ..., 'ts': ...}
+ if isinstance(msg, dict) and "msg" in msg:
+ actual_msg = msg["msg"]
+ return {
+ "msg": _serialize_obj(actual_msg),
+ "agent": msg.get("agent"),
+ "model": msg.get("model"),
+ "ts": msg.get("ts"),
+ }
+
+ # Handle unwrapped messages
+ result = _serialize_obj(msg)
+ if not isinstance(result, dict):
+ return {"content": str(result)}
+ return result
+
+
+def _load_json_sync(file_path: Path) -> dict:
+ """Synchronous JSON file load (for use in executor)."""
+ with open(file_path, "r") as f:
+ return json.load(f)
+
+
+def _load_pickle_sync(file_path: Path) -> Any:
+ """Synchronous pickle file load (for use in executor)."""
+ with open(file_path, "rb") as f:
+ return pickle.load(f)
+
+
+@router.get("/")
+async def list_sessions() -> List[SessionInfo]:
+ """List all available sessions.
+
+ Returns:
+ List of SessionInfo objects for each session found
+ """
+ sessions_dir = _get_sessions_dir()
+ if not sessions_dir.exists():
+ return []
+
+ loop = asyncio.get_running_loop()
+ sessions = []
+
+ for txt_file in sessions_dir.glob("*.txt"):
+ session_id = txt_file.stem
+ try:
+ # Run blocking I/O in thread pool with timeout
+ metadata = await asyncio.wait_for(
+ loop.run_in_executor(_executor, _load_json_sync, txt_file),
+ timeout=FILE_IO_TIMEOUT,
+ )
+ sessions.append(
+ SessionInfo(
+ session_id=session_id,
+ agent_name=metadata.get("agent_name"),
+ initial_prompt=metadata.get("initial_prompt"),
+ created_at=metadata.get("created_at"),
+ last_updated=metadata.get("last_updated"),
+ message_count=metadata.get("message_count", 0),
+ )
+ )
+ except asyncio.TimeoutError:
+ # Timed out reading file, include basic info
+ sessions.append(SessionInfo(session_id=session_id))
+ except Exception:
+ # If we can't parse metadata, still include basic session info
+ sessions.append(SessionInfo(session_id=session_id))
+
+ return sessions
+
+
+@router.get("/{session_id}")
+async def get_session(session_id: str) -> SessionInfo:
+ """Get session metadata.
+
+ Args:
+ session_id: The session identifier
+
+ Returns:
+ SessionInfo with metadata for the specified session
+
+ Raises:
+ HTTPException: 404 if session not found, 504 on timeout
+ """
+ sessions_dir = _get_sessions_dir()
+ txt_file = sessions_dir / f"{session_id}.txt"
+
+ if not txt_file.exists():
+ raise HTTPException(404, f"Session '{session_id}' not found")
+
+ loop = asyncio.get_running_loop()
+
+ try:
+ metadata = await asyncio.wait_for(
+ loop.run_in_executor(_executor, _load_json_sync, txt_file),
+ timeout=FILE_IO_TIMEOUT,
+ )
+ except asyncio.TimeoutError:
+ raise HTTPException(504, f"Timeout reading session '{session_id}'") from None
+
+ return SessionInfo(
+ session_id=session_id,
+ agent_name=metadata.get("agent_name"),
+ initial_prompt=metadata.get("initial_prompt"),
+ created_at=metadata.get("created_at"),
+ last_updated=metadata.get("last_updated"),
+ message_count=metadata.get("message_count", 0),
+ )
+
+
+@router.get("/{session_id}/messages")
+async def get_session_messages(session_id: str) -> List[Dict[str, Any]]:
+ """Get the full message history for a session.
+
+ Args:
+ session_id: The session identifier
+
+ Returns:
+ List of serialized message dictionaries
+
+ Raises:
+ HTTPException: 404 if session messages not found, 500 on load error, 504 on timeout
+ """
+ sessions_dir = _get_sessions_dir()
+ pkl_file = sessions_dir / f"{session_id}.pkl"
+
+ if not pkl_file.exists():
+ raise HTTPException(404, f"Session '{session_id}' messages not found")
+
+ loop = asyncio.get_running_loop()
+
+ try:
+ messages = await asyncio.wait_for(
+ loop.run_in_executor(_executor, _load_pickle_sync, pkl_file),
+ timeout=FILE_IO_TIMEOUT,
+ )
+ return [_serialize_message(msg) for msg in messages]
+ except asyncio.TimeoutError:
+ raise HTTPException(
+ 504, f"Timeout loading session '{session_id}' messages"
+ ) from None
+ except Exception as e:
+ raise HTTPException(500, f"Error loading session messages: {e}") from e
+
+
+@router.delete("/{session_id}")
+async def delete_session(session_id: str) -> Dict[str, str]:
+ """Delete a session and its data.
+
+ Args:
+ session_id: The session identifier
+
+ Returns:
+ Success message dict
+
+ Raises:
+ HTTPException: 404 if session not found
+ """
+ sessions_dir = _get_sessions_dir()
+ txt_file = sessions_dir / f"{session_id}.txt"
+ pkl_file = sessions_dir / f"{session_id}.pkl"
+
+ if not txt_file.exists() and not pkl_file.exists():
+ raise HTTPException(404, f"Session '{session_id}' not found")
+
+ if txt_file.exists():
+ txt_file.unlink()
+ if pkl_file.exists():
+ pkl_file.unlink()
+
+ return {"message": f"Session '{session_id}' deleted"}
diff --git a/code_puppy/api/routers/ws_sessions.py b/code_puppy/api/routers/ws_sessions.py
new file mode 100644
index 000000000..8a5cbb445
--- /dev/null
+++ b/code_puppy/api/routers/ws_sessions.py
@@ -0,0 +1,302 @@
+"""WebSocket session management router.
+
+Handles CRUD operations for chat sessions.
+Now exclusively uses SQLite as the source of truth.
+"""
+
+import json
+import logging
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+from fastapi import APIRouter, HTTPException, Query
+
+from code_puppy.api.db.queries import (
+ get_active_messages,
+ get_session_history_parity,
+ get_session_metadata,
+ get_session_tool_calls,
+ soft_delete_session,
+ update_session_meta_fields,
+)
+from code_puppy.config import get_ws_sessions_dir
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+
+
+def _get_ws_sessions_dir() -> Path:
+ """Get the WebSocket sessions directory."""
+ return get_ws_sessions_dir()
+
+
+def _validate_session_name(session_name: str, ws_dir: Path) -> str:
+ """Validate session name prevents path traversal."""
+ if not session_name or ".." in session_name or "/" in session_name:
+ raise HTTPException(400, "Invalid session name")
+ return session_name
+
+
+@router.get("/{session_name}/messages")
+async def get_ws_session_messages(
+ session_name: str,
+ include_tool_calls: Optional[bool] = Query(
+ default=None,
+ description="Include tool_call rows interleaved with messages (Full Parity Model). "
+ "When true, returns unified row format with row_type discriminator.",
+ ),
+ include_compacted: bool = Query(
+ default=True,
+ description="Include compacted (summarized) messages. Only applies when include_tool_calls=true.",
+ ),
+) -> List[Dict[str, Any]]:
+ """Get the full message history for a WebSocket session from SQLite.
+
+ Args:
+ session_name: The session name / session_id
+ include_tool_calls: Feature flag for Full Parity Model.
+ - None/False: Legacy behavior, returns only message rows
+ - True: Returns interleaved message + tool_call rows with row_type discriminator
+ include_compacted: When using parity mode, whether to include compacted messages
+
+ Returns:
+ List of serialized message dictionaries.
+
+ When include_tool_calls=true, each row has:
+ - row_type: 'message' | 'tool_call'
+ - row_id: unique identifier
+ - seq: ordering key
+ - Plus type-specific fields (content for messages, result_json for tool_calls, etc.)
+ """
+ _validate_session_name(session_name, _get_ws_sessions_dir())
+
+ try:
+ # ──────────────────────────────────────────────────────────────────
+ # Full Parity Model: interleaved messages + tool_calls
+ # ──────────────────────────────────────────────────────────────────
+ if include_tool_calls:
+ rows = await get_session_history_parity(
+ session_name,
+ include_compacted=include_compacted,
+ )
+
+ if rows:
+ logger.debug(
+ "get_ws_session_messages (parity): session=%s total_rows=%d "
+ "messages=%d tool_calls=%d",
+ session_name,
+ len(rows),
+ sum(1 for r in rows if r.get("row_type") == "message"),
+ sum(1 for r in rows if r.get("row_type") == "tool_call"),
+ )
+ return rows
+
+ # Check if session exists but has no data
+ meta = await get_session_metadata(session_name)
+ if meta:
+ return []
+
+ raise HTTPException(404, f"WebSocket session '{session_name}' not found")
+
+ # ──────────────────────────────────────────────────────────────────
+ # Legacy behavior: message rows only (backward compatible)
+ # ──────────────────────────────────────────────────────────────────
+ rows = await get_active_messages(session_name)
+ serialized_rows: List[Dict[str, Any]] = []
+
+ for row in rows:
+ base_row = {
+ "role": row["role"],
+ "content": row["content"],
+ "type": row["type"],
+ "agent_name": row["agent_name"],
+ "model_name": row["model_name"],
+ "timestamp": row["timestamp"],
+ "thinking": row["thinking"],
+ "clean_content": row["clean_content"],
+ "seq": row["seq"],
+ }
+
+ if row.get("pydantic_json"):
+ serialized_rows.append(base_row)
+ continue
+
+ if row.get("type") == "error":
+ payload: Dict[str, Any] = {}
+ raw_payload = row.get("attachments_json")
+ if raw_payload:
+ try:
+ payload = json.loads(raw_payload)
+ except (TypeError, ValueError):
+ logger.warning(
+ "get_ws_session_messages: invalid error payload JSON for %s seq=%s",
+ session_name,
+ row.get("seq"),
+ )
+
+ serialized_rows.append(
+ {
+ **base_row,
+ "type": "error",
+ "error": payload.get("error", row["content"]),
+ "error_type": payload.get("error_type", "unknown"),
+ "technical_details": payload.get("technical_details", ""),
+ "action_required": payload.get("action_required"),
+ "session_id": payload.get("session_id", session_name),
+ }
+ )
+ continue
+
+ if row.get("type") == "system":
+ serialized_rows.append(
+ {
+ **base_row,
+ "system_message_type": row.get("system_message_type"),
+ "system_message_path": row.get("system_message_path"),
+ }
+ )
+
+ if serialized_rows:
+ return serialized_rows
+
+ # If no messages found, check if session exists at all
+ meta = await get_session_metadata(session_name)
+ if meta:
+ # Session exists but has no messages yet - return empty list
+ return []
+
+ raise HTTPException(404, f"WebSocket session '{session_name}' not found")
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(
+ "get_ws_session_messages: SQLite error for %s: %s", session_name, e
+ )
+ raise HTTPException(503, "Service unavailable: database error")
+
+
+@router.get("/{session_name}/tool-calls")
+async def get_ws_session_tool_calls(session_name: str) -> List[Dict[str, Any]]:
+ """Get all tool calls for a session.
+
+ This is a diagnostic/debugging endpoint that returns just the tool_calls
+ table rows for a session, useful for verifying persistence.
+
+ Args:
+ session_name: The session name / session_id
+
+ Returns:
+ List of tool_call rows with id, tool_name, args_json, result_json, status, etc.
+ """
+ _validate_session_name(session_name, _get_ws_sessions_dir())
+
+ try:
+ rows = await get_session_tool_calls(session_name)
+
+ if rows:
+ logger.debug(
+ "get_ws_session_tool_calls: session=%s count=%d",
+ session_name,
+ len(rows),
+ )
+ return rows
+
+ # Check if session exists
+ meta = await get_session_metadata(session_name)
+ if meta:
+ return [] # Session exists but has no tool calls
+
+ raise HTTPException(404, f"WebSocket session '{session_name}' not found")
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(
+ "get_ws_session_tool_calls: SQLite error for %s: %s", session_name, e
+ )
+ raise HTTPException(503, "Service unavailable: database error")
+
+
+@router.delete("/{session_name}")
+async def delete_ws_session(session_name: str) -> Dict[str, str]:
+ """Soft-delete a WebSocket session in SQLite.
+
+ Also attempts to clean up any legacy pkl files if they exist.
+ """
+ ws_dir = _get_ws_sessions_dir()
+ safe_base = _validate_session_name(session_name, ws_dir)
+
+ # Soft delete in SQLite
+ try:
+ await soft_delete_session(session_name, datetime.now(timezone.utc).isoformat())
+ # We don't check if it existed, soft_delete is idempotent-ish
+ # But we might want to know if we actually updated anything.
+ # For now, we assume success if no error.
+ except Exception as e:
+ logger.error("delete_ws_session: SQLite error for %s: %s", session_name, e)
+ raise HTTPException(503, "Service unavailable: database error")
+
+ # Cleanup legacy files (best effort)
+ try:
+ pkl_file = ws_dir / f"{safe_base}.pkl"
+ meta_file = ws_dir / f"{safe_base}_meta.json"
+ if pkl_file.exists():
+ pkl_file.unlink()
+ if meta_file.exists():
+ meta_file.unlink()
+ except Exception:
+ pass
+
+ return {"message": f"WebSocket session '{session_name}' deleted"}
+
+
+@router.patch("/{session_name}")
+async def update_ws_session(
+ session_name: str, updates: Dict[str, Any]
+) -> Dict[str, Any]:
+ """Update WebSocket session metadata in SQLite.
+
+ Supports updating: title, pinned.
+ """
+ _validate_session_name(session_name, _get_ws_sessions_dir())
+
+ # Get current metadata
+ current_meta = await get_session_metadata(session_name)
+ if not current_meta:
+ raise HTTPException(404, f"WebSocket session '{session_name}' not found")
+
+ # Prepare updates
+ new_title = current_meta["title"]
+ new_pinned = bool(current_meta["pinned"])
+ updated_at = datetime.now(timezone.utc).isoformat()
+
+ if "title" in updates or "name" in updates:
+ val = updates.get("title") or updates.get("name")
+ if val and isinstance(val, str) and val.strip():
+ new_title = val.strip()
+
+ if "pinned" in updates:
+ if isinstance(updates["pinned"], bool):
+ new_pinned = updates["pinned"]
+
+ # Write to SQLite
+ try:
+ await update_session_meta_fields(
+ session_name,
+ title=new_title,
+ pinned=new_pinned,
+ updated_at=updated_at,
+ )
+ except Exception as e:
+ logger.error("update_ws_session: SQLite error for %s: %s", session_name, e)
+ raise HTTPException(503, "Service unavailable: database error")
+
+ return {
+ "session_id": session_name,
+ "title": new_title,
+ "pinned": new_pinned,
+ "updated_at": updated_at,
+ }
diff --git a/code_puppy/api/session_cache.py b/code_puppy/api/session_cache.py
new file mode 100644
index 000000000..235041418
--- /dev/null
+++ b/code_puppy/api/session_cache.py
@@ -0,0 +1,392 @@
+"""Server-side session caching for fast session loading.
+
+This module provides an LRU cache for deserialized session data,
+dramatically reducing load times for frequently accessed sessions.
+"""
+
+import asyncio
+import logging
+import pickle
+import time
+from collections import OrderedDict
+from concurrent.futures import ThreadPoolExecutor
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Tuple
+
+logger = logging.getLogger(__name__)
+
+# Configuration
+MAX_CACHED_SESSIONS = 50 # Maximum number of sessions to cache
+CACHE_TTL_SECONDS = 3600 # 1 hour TTL
+MAX_WORKERS = 4
+
+# Thread pool for async file I/O
+_executor = ThreadPoolExecutor(max_workers=MAX_WORKERS)
+
+
+class SessionCacheEntry:
+ """A cached session entry with metadata."""
+
+ __slots__ = [
+ "messages",
+ "json_messages",
+ "metadata",
+ "loaded_at",
+ "last_accessed",
+ "file_mtime",
+ ]
+
+ def __init__(
+ self,
+ messages: List[Any],
+ json_messages: List[Dict[str, Any]],
+ metadata: Dict[str, Any],
+ file_mtime: float,
+ ):
+ self.messages = messages
+ self.json_messages = json_messages # Pre-serialized for fast API response
+ self.metadata = metadata
+ self.loaded_at = time.time()
+ self.last_accessed = time.time()
+ self.file_mtime = file_mtime
+
+ @property
+ def age_seconds(self) -> float:
+ return time.time() - self.loaded_at
+
+ @property
+ def is_expired(self) -> bool:
+ return self.age_seconds > CACHE_TTL_SECONDS
+
+ def touch(self):
+ """Update last accessed time."""
+ self.last_accessed = time.time()
+
+
+class SessionCache:
+ """LRU cache for session data with async support.
+
+ Features:
+ - LRU eviction when cache is full
+ - TTL-based expiration
+ - File modification tracking for cache invalidation
+ - Pre-serialized JSON for fast API responses
+ - Thread-safe async operations
+ """
+
+ def __init__(self, max_size: int = MAX_CACHED_SESSIONS):
+ self._cache: OrderedDict[str, SessionCacheEntry] = OrderedDict()
+ self._max_size = max_size
+ self._lock = asyncio.Lock()
+ self._stats = {"hits": 0, "misses": 0, "evictions": 0, "expirations": 0}
+
+ async def get(
+ self, session_id: str, pkl_path: Path
+ ) -> Optional[Tuple[List[Dict[str, Any]], Dict[str, Any]]]:
+ """Get cached session messages (pre-serialized JSON).
+
+ Returns:
+ Tuple of (json_messages, metadata) if cached and valid, None otherwise
+ """
+ async with self._lock:
+ entry = self._cache.get(session_id)
+
+ if entry is None:
+ self._stats["misses"] += 1
+ return None
+
+ # Check TTL
+ if entry.is_expired:
+ self._stats["expirations"] += 1
+ del self._cache[session_id]
+ logger.debug("[Cache] Session %s expired", session_id)
+ return None
+
+ # Check if file was modified
+ try:
+ current_mtime = pkl_path.stat().st_mtime
+ if current_mtime > entry.file_mtime:
+ self._stats["expirations"] += 1
+ del self._cache[session_id]
+ logger.debug(
+ f"[Cache] Session {session_id} file modified, invalidating cache"
+ )
+ return None
+ except FileNotFoundError:
+ del self._cache[session_id]
+ return None
+
+ # Cache hit!
+ self._stats["hits"] += 1
+ entry.touch()
+
+ # Move to end (most recently used)
+ self._cache.move_to_end(session_id)
+
+ logger.debug(
+ f"[Cache] HIT for session {session_id} (age: {entry.age_seconds:.1f}s)"
+ )
+ return entry.json_messages, entry.metadata
+
+ async def put(
+ self,
+ session_id: str,
+ pkl_path: Path,
+ messages: List[Any],
+ json_messages: List[Dict[str, Any]],
+ metadata: Dict[str, Any],
+ ):
+ """Cache session data.
+
+ Args:
+ session_id: Unique session identifier
+ pkl_path: Path to the pickle file (for mtime tracking)
+ messages: Raw message objects
+ json_messages: Pre-serialized JSON messages
+ metadata: Session metadata
+ """
+ async with self._lock:
+ # Evict if at capacity
+ while len(self._cache) >= self._max_size:
+ # Remove oldest (first) item
+ oldest_key = next(iter(self._cache))
+ del self._cache[oldest_key]
+ self._stats["evictions"] += 1
+ logger.debug("[Cache] Evicted session %s", oldest_key)
+
+ # Get file mtime
+ try:
+ file_mtime = pkl_path.stat().st_mtime
+ except FileNotFoundError:
+ file_mtime = time.time()
+
+ # Create entry
+ entry = SessionCacheEntry(
+ messages=messages,
+ json_messages=json_messages,
+ metadata=metadata,
+ file_mtime=file_mtime,
+ )
+
+ self._cache[session_id] = entry
+ logger.debug(
+ f"[Cache] Cached session {session_id} ({len(messages)} messages)"
+ )
+
+ async def invalidate(self, session_id: str):
+ """Remove a session from cache."""
+ async with self._lock:
+ if session_id in self._cache:
+ del self._cache[session_id]
+ logger.debug("[Cache] Invalidated session %s", session_id)
+
+ async def clear(self):
+ """Clear all cached sessions."""
+ async with self._lock:
+ self._cache.clear()
+ logger.info("[Cache] All sessions cleared")
+
+ @property
+ def stats(self) -> Dict[str, Any]:
+ """Get cache statistics."""
+ hit_rate = 0.0
+ total = self._stats["hits"] + self._stats["misses"]
+ if total > 0:
+ hit_rate = self._stats["hits"] / total * 100
+
+ return {
+ **self._stats,
+ "size": len(self._cache),
+ "max_size": self._max_size,
+ "hit_rate_percent": round(hit_rate, 1),
+ }
+
+
+# Global cache instance
+_session_cache: Optional[SessionCache] = None
+
+
+def get_session_cache() -> SessionCache:
+ """Get the global session cache instance."""
+ global _session_cache
+ if _session_cache is None:
+ _session_cache = SessionCache()
+ return _session_cache
+
+
+def _load_pickle_sync(pkl_path: Path) -> List[Any]:
+ """Synchronous pickle loading (for executor)."""
+ with open(pkl_path, "rb") as f:
+ return pickle.load(f)
+
+
+def _serialize_message(msg: Any) -> Dict[str, Any]:
+ """Serialize a pydantic-ai message to JSON-safe dict.
+
+ Handles both wrapped and unwrapped message formats:
+ - Wrapped (WS sessions): {'msg': , 'agent': ..., 'model': ..., 'ts': ...}
+ - Unwrapped: or
+
+ Preserves all message parts including ThinkingPart, ToolCallPart, TextPart, etc.
+ """
+
+ def _serialize_obj(obj: Any) -> Any:
+ """Recursively serialize an object to JSON-safe types."""
+ # Pydantic v2 models with model_dump
+ if hasattr(obj, "model_dump"):
+ return obj.model_dump(mode="json")
+
+ # Handle dicts recursively
+ if isinstance(obj, dict):
+ return {k: _serialize_obj(v) for k, v in obj.items()}
+
+ # Handle lists/tuples recursively
+ if isinstance(obj, (list, tuple)):
+ return [_serialize_obj(item) for item in obj]
+
+ # JSON-safe primitives
+ if isinstance(obj, (str, int, float, bool, type(None))):
+ return obj
+
+ # pydantic-ai message objects (ModelRequest, ModelResponse, Part subclasses)
+ # They have __dict__ but are not Pydantic models
+ if hasattr(obj, "__dict__"):
+ result = {}
+ for k, v in obj.__dict__.items():
+ result[k] = _serialize_obj(v)
+ return result
+
+ # Fallback: convert to string
+ return str(obj)
+
+ # Handle wrapped message format (used in WS sessions)
+ # {'msg': , 'agent': ..., 'model': ..., 'ts': ...}
+ if isinstance(msg, dict) and "msg" in msg:
+ actual_msg = msg["msg"]
+ return {
+ "msg": _serialize_obj(actual_msg),
+ "agent": msg.get("agent"),
+ "model": msg.get("model"),
+ "ts": msg.get("ts"),
+ }
+
+ # Handle unwrapped messages
+ return _serialize_obj(msg)
+
+
+def _serialize_messages_sync(messages: List[Any]) -> List[Dict[str, Any]]:
+ """Synchronous batch serialization (for executor).
+
+ Serializes all messages to JSON-safe dicts. Runs in thread pool
+ to avoid blocking the event loop for large sessions.
+ """
+ return [_serialize_message(msg) for msg in messages]
+
+
+async def load_session_cached(
+ session_id: str, pkl_path: Path, timeout: float = 10.0
+) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
+ """Load session messages with caching.
+
+ This is the main entry point for loading session data.
+ It checks the cache first, and only loads from disk on cache miss.
+
+ Args:
+ session_id: Unique session identifier
+ pkl_path: Path to the pickle file
+ timeout: Timeout for file operations
+
+ Returns:
+ Tuple of (json_messages, metadata)
+
+ Raises:
+ FileNotFoundError: If pickle file doesn't exist
+ asyncio.TimeoutError: If load times out
+ """
+ cache = get_session_cache()
+
+ # Try cache first
+ cached = await cache.get(session_id, pkl_path)
+ if cached is not None:
+ return cached
+
+ # Cache miss - load from disk
+ if not pkl_path.exists():
+ raise FileNotFoundError(f"Session file not found: {pkl_path}")
+
+ loop = asyncio.get_running_loop()
+
+ # Load pickle in thread pool
+ start_time = time.time()
+ messages = await asyncio.wait_for(
+ loop.run_in_executor(_executor, _load_pickle_sync, pkl_path), timeout=timeout
+ )
+ load_time = time.time() - start_time
+
+ # Serialize to JSON in thread pool to avoid blocking event loop
+ json_messages = await loop.run_in_executor(
+ _executor, _serialize_messages_sync, messages
+ )
+
+ # Build metadata
+ metadata = {
+ "message_count": len(messages),
+ "load_time_ms": round(load_time * 1000, 1),
+ }
+
+ # Cache for next time
+ await cache.put(session_id, pkl_path, messages, json_messages, metadata)
+
+ logger.info(
+ f"[Cache] Loaded session {session_id} from disk in {load_time * 1000:.1f}ms"
+ )
+
+ return json_messages, metadata
+
+
+async def invalidate_session_cache(session_id: str):
+ """Invalidate a specific session in the cache.
+
+ Call this when a session is modified or deleted.
+ """
+ cache = get_session_cache()
+ await cache.invalidate(session_id)
+
+
+async def get_cache_stats() -> Dict[str, Any]:
+ """Get cache statistics for monitoring."""
+ cache = get_session_cache()
+ return cache.stats
+
+
+async def shutdown_executor() -> None:
+ """Shutdown the thread pool executor gracefully.
+
+ This function ensures all pending tasks are completed before shutdown
+ and prevents resource leaks. Should be called during application shutdown.
+
+ Thread-safe and idempotent - safe to call multiple times.
+ """
+ global _executor
+
+ if _executor is None:
+ logger.debug("[Executor] Already shut down or never initialized")
+ return
+
+ try:
+ logger.info("[Executor] Shutting down thread pool executor...")
+
+ # Shutdown gracefully - wait for current tasks to complete
+ # but don't cancel futures (they might be critical)
+ _executor.shutdown(wait=True, cancel_futures=False)
+
+ logger.info("[Executor] Thread pool executor shutdown complete")
+
+ # Clear the reference
+ _executor = None
+
+ except Exception as e:
+ logger.error("[Executor] Error during shutdown: %s", e, exc_info=True)
+ # Still clear the reference even if shutdown had issues
+ _executor = None
+ raise
diff --git a/code_puppy/api/session_context.py b/code_puppy/api/session_context.py
new file mode 100644
index 000000000..a8621e1bf
--- /dev/null
+++ b/code_puppy/api/session_context.py
@@ -0,0 +1,675 @@
+"""Multi-session isolation layer for Code Puppy.
+
+Provides per-session state management so that each browser tab / WebSocket
+connection gets its own agent instance, model selection, working directory,
+and message history — without touching any global singletons.
+
+Usage:
+ from code_puppy.api.session_context import session_manager
+
+ ctx = session_manager.create_session("my-session-123")
+ ctx.agent.run_sync("Hello!") # uses the session’s own agent
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import re
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+from typing import Any, Dict, List, Optional, Set
+
+from code_puppy.agents.agent_manager import get_available_agents, load_agent
+from code_puppy.agents.base_agent import BaseAgent
+from code_puppy.api.db.message_utils import (
+ extract_content,
+ extract_thinking,
+ get_message_timestamp,
+ get_role,
+ pydantic_json_for_message,
+)
+from code_puppy.config import get_global_model_name
+
+logger = logging.getLogger(__name__)
+
+
+def _apply_session_model(agent: Any, model_name: Optional[str]) -> None:
+ """Best-effort session-model setter for migration compatibility.
+
+ Some migrated/legacy agent classes may not yet implement
+ ``set_session_model``. In that case we set the internal override field
+ directly to avoid failing model switches at runtime.
+ """
+ setter = getattr(agent, "set_session_model", None)
+ if callable(setter):
+ setter(model_name)
+ return
+
+ # Last-resort compatibility for older agent implementations
+ try:
+ setattr(agent, "_session_model_name", model_name or None)
+ except Exception:
+ pass
+
+
+def _reload_agent_if_supported(agent: Any) -> None:
+ reloader = getattr(agent, "reload_code_generation_agent", None)
+ if callable(reloader):
+ reloader()
+
+
+# ---------------------------------------------------------------------------
+# Validation helpers
+# ---------------------------------------------------------------------------
+
+_SAFE_SESSION_ID_RE = re.compile(r"^[\w.\-]{1,256}$", re.ASCII)
+
+
+def _validate_session_id(session_id: str) -> None:
+ """Reject session IDs that could cause path-traversal or other mischief."""
+ if not session_id or not _SAFE_SESSION_ID_RE.match(session_id):
+ raise ValueError(
+ f"Invalid session_id: {session_id!r}. "
+ "Must be 1–256 chars of [a-zA-Z0-9_.-]."
+ )
+ # Extra guard: no `..` anywhere
+ if ".." in session_id:
+ raise ValueError(f"Path traversal detected in session_id: {session_id!r}")
+
+
+def _validate_agent_name(agent_name: str) -> None:
+ """Ensure the agent actually exists in the registry."""
+ available = get_available_agents()
+ if agent_name not in available:
+ raise ValueError(
+ f"Unknown agent {agent_name!r}. Available: {', '.join(sorted(available))}"
+ )
+
+
+@dataclass
+class SessionContext:
+ """All mutable state scoped to a single user session.
+
+ Every field lives *only* on this object — nothing is written to global
+ singletons like ``_SESSION_MODEL`` or ``_CURRENT_AGENT``.
+ """
+
+ session_id: str
+ agent: BaseAgent
+ agent_name: str
+ model_name: str
+ working_directory: str
+ title: str = ""
+ pinned: bool = False
+ created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+ websocket: Any | None = None
+
+ # Per-agent-name history cache (survives agent switches within session)
+ _saved_histories: Dict[str, List[Any]] = field(default_factory=dict)
+ # Per-agent-name compacted-message hashes
+ _compacted_hashes: Dict[str, Set[str]] = field(default_factory=dict)
+ # Serialises mutations (switch_agent, switch_model, save) on this session.
+ # Stored as Optional so it can be created lazily inside a running event loop.
+ _op_lock: Optional[asyncio.Lock] = field(default=None, repr=False, compare=False)
+
+ @property
+ def op_lock(self) -> asyncio.Lock:
+ """Lazy asyncio.Lock — created on first access inside the event loop."""
+ if self._op_lock is None:
+ self._op_lock = asyncio.Lock()
+ return self._op_lock
+
+ async def switch_agent_inline(self, new_agent_name: str) -> "BaseAgent":
+ """Hot-swap the agent on this context, preserving/restoring history.
+
+ Unlike SessionManager.switch_agent(), operates directly on self without
+ requiring a session_id lookup. Used by MuxChatHandler which manages
+ the runtime reference directly.
+
+ Returns:
+ The newly loaded BaseAgent.
+ """
+ from code_puppy.agent_loader import load_agent # local to avoid circular import
+
+ old_name = self.agent_name
+
+ async with self.op_lock:
+ # Persist outgoing agent state
+ if hasattr(self.agent, "get_message_history"):
+ self._saved_histories[old_name] = self.agent.get_message_history()
+ if hasattr(self.agent, "get_compacted_message_hashes"):
+ self._compacted_hashes[old_name] = (
+ self.agent.get_compacted_message_hashes()
+ )
+
+ # Load new agent
+ new_agent = load_agent(new_agent_name)
+
+ # Restore history if we've visited this agent before
+ if new_agent_name in self._saved_histories and hasattr(
+ new_agent, "set_message_history"
+ ):
+ new_agent.set_message_history(self._saved_histories[new_agent_name])
+ if new_agent_name in self._compacted_hashes and hasattr(
+ new_agent, "add_compacted_message_hash"
+ ):
+ for h in self._compacted_hashes[new_agent_name]:
+ new_agent.add_compacted_message_hash(h)
+
+ # Carry session model across
+ _apply_session_model(new_agent, self.model_name)
+ _reload_agent_if_supported(new_agent)
+
+ self.agent = new_agent
+ self.agent_name = new_agent_name
+
+ return new_agent
+
+
+# ---------------------------------------------------------------------------
+# SessionManager
+# ---------------------------------------------------------------------------
+
+
+class SessionManager:
+ """Thread-safe registry of active ``SessionContext`` instances."""
+
+ _SESSION_RETENTION_SECONDS = 15 * 60 # 15 minutes
+
+ def __init__(self) -> None:
+ self._sessions: Dict[str, SessionContext] = {}
+ self._lock = asyncio.Lock()
+ self._inactive_since: Dict[
+ str, datetime
+ ] = {} # Track when session became inactive
+ self._cleanup_task: Optional[asyncio.Task] = None # Background cleanup task
+
+ # -- creation / lookup / teardown ------------------------------------
+
+ async def create_session(
+ self,
+ session_id: str,
+ agent_name: str = "code-puppy",
+ model_name: Optional[str] = None,
+ working_directory: str = "",
+ ) -> SessionContext:
+ """Spin up a brand-new isolated session.
+
+ Args:
+ session_id: Unique identifier (validated for path safety).
+ agent_name: Which agent to load (default ``code-puppy``).
+ model_name: Session-local model override. ``None`` → global default.
+ working_directory: Session-local CWD (never calls ``os.chdir``).
+
+ Returns:
+ The freshly-created ``SessionContext``.
+
+ Raises:
+ ValueError: On invalid *session_id* or unknown *agent_name*.
+ """
+ _validate_session_id(session_id)
+ _validate_agent_name(agent_name)
+
+ agent = load_agent(agent_name)
+ resolved_model = model_name or get_global_model_name()
+
+ if model_name is not None:
+ _apply_session_model(agent, model_name)
+
+ ctx = SessionContext(
+ session_id=session_id,
+ agent=agent,
+ agent_name=agent_name,
+ model_name=resolved_model,
+ working_directory=working_directory,
+ )
+
+ async with self._lock:
+ self._sessions[session_id] = ctx
+
+ logger.info(
+ "Session created: id=%s agent=%s model=%s",
+ session_id,
+ agent_name,
+ resolved_model,
+ )
+ return ctx
+
+ async def get_session(self, session_id: str) -> Optional[SessionContext]:
+ """Look up an active session. Returns ``None`` if not found."""
+ async with self._lock:
+ return self._sessions.get(session_id)
+
+ async def destroy_session(self, session_id: str) -> None:
+ """Remove a session from the registry."""
+ async with self._lock:
+ removed = self._sessions.pop(session_id, None)
+ if removed:
+ logger.info("Session destroyed: id=%s", session_id)
+ else:
+ logger.warning("destroy_session called for unknown id=%s", session_id)
+
+ # -- agent switching -------------------------------------------------
+
+ async def switch_agent(
+ self,
+ session_id: str,
+ new_agent_name: str,
+ ) -> BaseAgent:
+ """Hot-swap the agent inside an existing session.
+
+ * Saves current agent’s history & compacted hashes.
+ * Loads a fresh agent for *new_agent_name*.
+ * Restores any previously-saved history if switching *back*.
+ * Carries over the session model.
+
+ Returns:
+ The newly-loaded ``BaseAgent``.
+
+ Raises:
+ KeyError: If *session_id* is unknown.
+ ValueError: If *new_agent_name* is invalid.
+ """
+ ctx = await self._require_session(session_id)
+ _validate_agent_name(new_agent_name)
+
+ async with ctx.op_lock: # Serialise all mutations on this session
+ old_name = ctx.agent_name
+
+ # Persist outgoing agent's state
+ ctx._saved_histories[old_name] = ctx.agent.get_message_history()
+ ctx._compacted_hashes[old_name] = ctx.agent.get_compacted_message_hashes()
+
+ # Build the replacement agent
+ new_agent = load_agent(new_agent_name)
+
+ # Restore history if we've visited this agent before
+ if new_agent_name in ctx._saved_histories:
+ new_agent.set_message_history(ctx._saved_histories[new_agent_name])
+ if new_agent_name in ctx._compacted_hashes:
+ for h in ctx._compacted_hashes[new_agent_name]:
+ new_agent.add_compacted_message_hash(h)
+
+ # Carry session model across agents
+ _apply_session_model(new_agent, ctx.model_name)
+ _reload_agent_if_supported(new_agent)
+
+ ctx.agent = new_agent
+ ctx.agent_name = new_agent_name
+
+ logger.info(
+ "Session %s: switched agent %s → %s",
+ session_id,
+ old_name,
+ new_agent_name,
+ )
+ return new_agent
+
+ # -- model switching -------------------------------------------------
+
+ async def switch_model(self, session_id: str, new_model: str) -> None:
+ """Change the model for a session without touching global config.
+
+ Raises:
+ KeyError: If *session_id* is unknown.
+ """
+ ctx = await self._require_session(session_id)
+ async with ctx.op_lock:
+ ctx.model_name = new_model
+ _apply_session_model(ctx.agent, new_model)
+ _reload_agent_if_supported(ctx.agent)
+ logger.info("Session %s: model → %s", session_id, new_model)
+
+ # -- persistence -----------------------------------------------------
+
+ async def _write_to_sqlite(
+ self,
+ session_id: str,
+ ctx: "SessionContext",
+ history: List[Any],
+ ) -> None:
+ """Write session metadata + any new messages to SQLite.
+
+ Called from save_session() as the sole durable write path.
+ All errors are caught — a DB failure never breaks the WS session.
+
+ Uses INSERT OR IGNORE on (session_id, seq) so re-running after a
+ partial write is fully idempotent.
+ """
+ try:
+ from code_puppy.api.db.queries import (
+ insert_messages_batch,
+ update_session_stats,
+ upsert_session,
+ )
+ except Exception as exc:
+ logger.debug("SQLite not available, skipping DB write: %s", exc)
+ return
+
+ from datetime import datetime
+ from datetime import timezone as _tz
+
+ updated_at = datetime.now(_tz.utc).isoformat()
+
+ try:
+ # Upsert session row (creates it if this is the first save)
+ await upsert_session(
+ session_id=session_id,
+ title=ctx.title,
+ agent_name=ctx.agent_name,
+ model_name=ctx.model_name,
+ working_directory=ctx.working_directory,
+ pinned=ctx.pinned,
+ created_at=ctx.created_at.isoformat(),
+ updated_at=updated_at,
+ message_count=len(history),
+ total_tokens=0, # updated below
+ deleted_at=None,
+ )
+ except Exception as exc:
+ logger.warning("Failed to upsert session %s in SQLite: %s", session_id, exc)
+ return
+
+ # Build message rows — seq is 1-based index into the full history.
+ # INSERT OR IGNORE on UNIQUE(session_id, seq) makes this idempotent.
+ message_rows = []
+ total_tokens = 0
+
+ for idx, msg in enumerate(history):
+ # Skip system dict entries that aren't proper ModelMessages
+ if isinstance(msg, dict) and not hasattr(msg, "parts"):
+ continue
+
+ seq = idx + 1
+ ts = get_message_timestamp(msg) or updated_at
+ content = extract_content(msg)
+ thinking = extract_thinking(msg)
+ pj = pydantic_json_for_message(msg)
+
+ try:
+ token_count = ctx.agent.estimate_tokens_for_message(msg)
+ except Exception:
+ token_count = max(1, len(content) // 4)
+
+ total_tokens += token_count
+
+ message_rows.append(
+ {
+ "session_id": session_id,
+ "seq": seq,
+ "role": get_role(msg),
+ "content": content,
+ "type": type(msg).__name__,
+ "agent_name": ctx.agent_name,
+ "model_name": ctx.model_name,
+ "timestamp": ts,
+ "thinking": thinking,
+ "token_count": token_count,
+ "pydantic_json": pj,
+ }
+ )
+
+ try:
+ await insert_messages_batch(message_rows)
+ except Exception as exc:
+ logger.warning(
+ "Failed to insert messages for %s in SQLite: %s", session_id, exc
+ )
+ return
+
+ # Update the token count now that we've summed it
+ try:
+ await update_session_stats(
+ session_id,
+ message_count=len(history),
+ total_tokens=total_tokens,
+ updated_at=updated_at,
+ )
+ except Exception as exc:
+ logger.warning(
+ "Failed to update session stats for %s in SQLite: %s", session_id, exc
+ )
+
+ logger.debug(
+ "Session %s written to SQLite (%d messages, %d tokens)",
+ session_id,
+ len(history),
+ total_tokens,
+ )
+
+ async def save_session(self, session_id: str) -> None:
+ """Persist session history and metadata to SQLite (single source of truth).
+
+ Previously wrote to {WS_SESSION_DIR}/{session_id}.pkl — that pkl write
+ has been removed. SQLite at ~/.puppy_desk/chat_messages.db is now the
+ only durable store.
+
+ aiosqlite serialises all DB I/O through its own background thread so
+ this coroutine yields to the event loop on every await — no
+ thread-pool needed.
+ """
+ ctx = await self._require_session(session_id)
+ _validate_session_id(session_id)
+
+ async with ctx.op_lock:
+ history: List[Any] = ctx.agent.get_message_history()
+
+ await self._write_to_sqlite(session_id, ctx, history)
+ logger.debug(
+ "Session %s saved to SQLite (%d messages)", session_id, len(history)
+ )
+
+ async def _load_from_sqlite(
+ self, session_id: str
+ ) -> Optional[tuple[List[Any], dict]]:
+ """Try to load message history + metadata from SQLite.
+
+ Returns (messages, meta_dict) on success, None if SQLite is unavailable
+ or the session has no pydantic_json rows (seeded without BE).
+ """
+ try:
+ from code_puppy.api.db.queries import get_active_messages, session_exists
+ except Exception:
+ return None
+
+ try:
+ if not await session_exists(session_id):
+ return None
+
+ rows = await get_active_messages(session_id)
+ # Filter to only rows with pydantic_json (seeder may leave some NULL).
+ # Sessions with no parseable messages still resume — just with empty history.
+ rows_with_json = [r for r in rows if r.get("pydantic_json")]
+
+ from pydantic_ai.messages import ModelMessagesTypeAdapter
+
+ messages: List[Any] = []
+ for row in rows_with_json:
+ try:
+ parsed = ModelMessagesTypeAdapter.validate_json(
+ row["pydantic_json"]
+ )
+ if parsed:
+ messages.append(parsed[0])
+ except Exception as exc:
+ logger.warning(
+ "Failed to deserialise message seq=%s for session %s: %s",
+ row.get("seq"),
+ session_id,
+ exc,
+ )
+
+ # Build a metadata dict from the sessions table
+ try:
+ from code_puppy.api.db.connection import get_db
+
+ db = get_db()
+ cursor = await db.execute(
+ "SELECT * FROM sessions WHERE session_id = ?", (session_id,)
+ )
+ row_s = await cursor.fetchone()
+ meta: dict = dict(row_s) if row_s else {}
+ except Exception:
+ meta = {}
+
+ logger.info(
+ "Session %s loaded from SQLite (%d messages)",
+ session_id,
+ len(messages),
+ )
+ return messages, meta
+
+ except Exception as exc:
+ logger.warning(
+ "SQLite load failed for session %s: %s",
+ session_id,
+ exc,
+ )
+ return None
+
+ async def load_session(self, session_id: str) -> Optional[SessionContext]:
+ """Restore a session from disk, creating a fresh agent.
+
+ Returns ``None`` if no persisted data exists or loading fails.
+ """
+ _validate_session_id(session_id)
+
+ # ---- try SQLite first (sessions saved by BE write path) ----------
+ sqlite_result = await self._load_from_sqlite(session_id)
+ if sqlite_result is not None:
+ messages, db_meta = sqlite_result
+
+ agent_name = db_meta.get("agent_name", "code-puppy")
+ model_name = db_meta.get("model_name", get_global_model_name())
+ title = db_meta.get("title", "")
+ pinned = bool(db_meta.get("pinned", False))
+ working_directory = db_meta.get("working_directory", "")
+ created_at_raw = db_meta.get("created_at", "")
+ try:
+ created_at = (
+ datetime.fromisoformat(created_at_raw)
+ if created_at_raw
+ else datetime.now(timezone.utc)
+ )
+ except Exception:
+ created_at = datetime.now(timezone.utc)
+
+ try:
+ agent = load_agent(agent_name)
+ except ValueError:
+ logger.warning(
+ "Agent %s unavailable, falling back to code-puppy", agent_name
+ )
+ agent_name = "code-puppy"
+ agent = load_agent(agent_name)
+
+ agent.set_message_history(messages)
+ _apply_session_model(agent, model_name)
+
+ ctx = SessionContext(
+ session_id=session_id,
+ agent=agent,
+ agent_name=agent_name,
+ model_name=model_name,
+ working_directory=working_directory,
+ title=title,
+ pinned=pinned,
+ created_at=created_at,
+ )
+
+ async with self._lock:
+ self._sessions[session_id] = ctx
+
+ return ctx
+ # SQLite is the single source of truth — no pkl fallback.
+ # If _load_from_sqlite() returned None the session does not exist in the DB.
+ logger.debug("Session %s not found in SQLite — treating as new", session_id)
+ return None
+
+ # -- session retention (15-min inactive cleanup) -------------------------
+
+ async def mark_session_inactive(self, session_id: str) -> None:
+ """Mark a session as inactive (WS disconnected). Starts retention timer."""
+ async with self._lock:
+ if session_id in self._sessions:
+ self._inactive_since[session_id] = datetime.now(timezone.utc)
+ logger.debug(
+ "Session %s marked inactive, will be cleaned up in 15 min",
+ session_id,
+ )
+ # Ensure cleanup task is running
+ self._ensure_cleanup_task()
+
+ async def mark_session_active(self, session_id: str) -> None:
+ """Mark a session as active (WS connected). Cancels pending cleanup."""
+ async with self._lock:
+ self._inactive_since.pop(session_id, None)
+ logger.debug("Session %s marked active", session_id)
+
+ def _ensure_cleanup_task(self) -> None:
+ """Start the background cleanup task if not already running."""
+ if self._cleanup_task is None or self._cleanup_task.done():
+ self._cleanup_task = asyncio.create_task(self._cleanup_inactive_sessions())
+
+ async def _cleanup_inactive_sessions(self) -> None:
+ """Background task that cleans up sessions inactive for > 15 minutes."""
+ while True:
+ await asyncio.sleep(60) # Check every minute
+ now = datetime.now(timezone.utc)
+ to_cleanup: List[str] = []
+
+ async with self._lock:
+ for session_id, inactive_time in list(self._inactive_since.items()):
+ elapsed = (now - inactive_time).total_seconds()
+ if elapsed > self._SESSION_RETENTION_SECONDS:
+ to_cleanup.append(session_id)
+
+ for session_id in to_cleanup:
+ logger.info(
+ "Cleaning up inactive session %s (inactive > 15 min)", session_id
+ )
+ await self.destroy_session(session_id)
+ async with self._lock:
+ self._inactive_since.pop(session_id, None)
+
+ # Stop task if no more inactive sessions to monitor
+ async with self._lock:
+ if not self._inactive_since:
+ break
+
+ async def get_or_load_session(self, session_id: str) -> Optional[SessionContext]:
+ """Get an existing in-memory session OR load from SQLite.
+
+ This is the preferred method for session access - it checks
+ in-memory first (including inactive sessions pending cleanup),
+ then falls back to SQLite load.
+ """
+ # First check if session exists in memory (active or inactive)
+ async with self._lock:
+ ctx = self._sessions.get(session_id)
+ if ctx is not None:
+ # Session exists in memory - mark it active and return
+ self._inactive_since.pop(session_id, None)
+ logger.debug("Reusing in-memory session %s", session_id)
+ return ctx
+
+ # Not in memory - try to load from SQLite
+ return await self.load_session(session_id)
+
+ # -- internal helpers ------------------------------------------------
+
+ async def _require_session(self, session_id: str) -> SessionContext:
+ """Return the session or raise ``KeyError``."""
+ async with self._lock:
+ ctx = self._sessions.get(session_id)
+ if ctx is None:
+ raise KeyError(f"No active session with id={session_id!r}")
+ return ctx
+
+
+# ---------------------------------------------------------------------------
+# Module-level singleton
+# ---------------------------------------------------------------------------
+
+session_manager = SessionManager()
diff --git a/code_puppy/api/tool_formatters.py b/code_puppy/api/tool_formatters.py
new file mode 100644
index 000000000..dc571818f
--- /dev/null
+++ b/code_puppy/api/tool_formatters.py
@@ -0,0 +1,138 @@
+"""
+Tool Call Argument Formatters
+
+Provides human-readable formatting for different tool types.
+Used by permission system to display tool details in a user-friendly way.
+"""
+
+from typing import Any, Dict
+
+
+def format_shell_command(args: Dict[str, Any]) -> str:
+ """Format shell command arguments for display."""
+ command = args.get("command", "")
+ cwd = args.get("cwd")
+ timeout = args.get("timeout", 60)
+ background = args.get("background", False)
+
+ lines = []
+ lines.append(f"Command: {command}")
+ if cwd:
+ lines.append(f"Directory: {cwd}")
+ lines.append(f"Timeout: {timeout}s")
+ if background:
+ lines.append("Mode: Background")
+
+ return "\n".join(lines)
+
+
+def format_file_operation(tool_name: str, args: Dict[str, Any]) -> str:
+ """Format file operation arguments for display."""
+ file_path = args.get("file_path", "")
+
+ lines = []
+ if "read_file" in tool_name.lower():
+ lines.append(f"Read file: {file_path}")
+ if "start_line" in args:
+ lines.append(
+ f"Lines: {args['start_line']}-{args['start_line'] + args.get('num_lines', 0)}"
+ )
+ elif "edit_file" in tool_name.lower() or "write_file" in tool_name.lower():
+ lines.append(f"Edit file: {file_path}")
+ content_preview = str(args.get("new_content", ""))[:100]
+ if len(content_preview) == 100:
+ content_preview += "..."
+ lines.append(f"Content preview: {content_preview}")
+ elif "delete_file" in tool_name.lower():
+ lines.append(f"⚠️ DELETE file: {file_path}")
+ else:
+ lines.append(f"File: {file_path}")
+
+ return "\n".join(lines)
+
+
+def format_agent_invocation(args: Dict[str, Any]) -> str:
+ """Format agent invocation arguments for display."""
+ agent_name = args.get("agent_name", "")
+ prompt = args.get("prompt", "")
+ session_id = args.get("session_id")
+
+ lines = []
+ lines.append(f"Agent: {agent_name}")
+
+ # Truncate long prompts
+ prompt_preview = prompt[:200]
+ if len(prompt) > 200:
+ prompt_preview += "..."
+ lines.append(f"Prompt: {prompt_preview}")
+
+ if session_id:
+ lines.append(f"Session: {session_id}")
+
+ return "\n".join(lines)
+
+
+def format_grep(args: Dict[str, Any]) -> str:
+ """Format grep arguments for display."""
+ search_string = args.get("search_string", "")
+ directory = args.get("directory", ".")
+
+ return f"Search: {search_string}\nDirectory: {directory}"
+
+
+def format_list_files(args: Dict[str, Any]) -> str:
+ """Format list_files arguments for display."""
+ directory = args.get("directory", ".")
+ recursive = args.get("recursive", True)
+
+ mode = "Recursive" if recursive else "Non-recursive"
+ return f"List files: {directory}\nMode: {mode}"
+
+
+def format_tool_call(tool_name: str, args: Dict[str, Any]) -> str:
+ """
+ Format tool call arguments in a human-readable way.
+
+ Args:
+ tool_name: Name of the tool being called
+ args: Tool arguments dict
+
+ Returns:
+ Human-readable formatted string
+ """
+ # Shell commands
+ if "shell" in tool_name.lower() or "command" in tool_name.lower():
+ return format_shell_command(args)
+
+ # File operations
+ if any(
+ word in tool_name.lower()
+ for word in ["file", "read", "write", "edit", "delete"]
+ ):
+ return format_file_operation(tool_name, args)
+
+ # Agent invocations
+ if "agent" in tool_name.lower() or "invoke" in tool_name.lower():
+ return format_agent_invocation(args)
+
+ # Grep
+ if "grep" in tool_name.lower() or "search" in tool_name.lower():
+ return format_grep(args)
+
+ # List files
+ if "list" in tool_name.lower():
+ return format_list_files(args)
+
+ # Generic fallback - show all args
+ lines = []
+ for key, value in args.items():
+ value_str = str(value)
+ if len(value_str) > 100:
+ value_str = value_str[:100] + "..."
+ lines.append(f"{key}: {value_str}")
+
+ return "\n".join(lines) if lines else "No arguments"
+
+
+# Export main function
+__all__ = ["format_tool_call"]
diff --git a/code_puppy/api/websocket.py b/code_puppy/api/websocket.py
new file mode 100644
index 000000000..f6208f104
--- /dev/null
+++ b/code_puppy/api/websocket.py
@@ -0,0 +1,51 @@
+"""WebSocket endpoints for Code Puppy API.
+
+Provides real-time communication channels:
+- /ws/events - Server-sent events stream
+- /ws/chat - Interactive chat with the agent
+- /ws/terminal - Interactive PTY terminal sessions
+- /ws/health - Simple health check endpoint
+- /ws/sessions - Real-time session monitoring
+
+This module is the thin entry point that registers all WebSocket endpoints.
+Each handler lives in its own module under `code_puppy.api.ws.*` for
+maintainability.
+"""
+
+import logging
+
+from fastapi import FastAPI
+
+from code_puppy.api.ws import (
+ register_chat_endpoint,
+ register_events_endpoint,
+ register_health_endpoint,
+ register_sessions_endpoint,
+ register_terminal_endpoint,
+)
+
+# Re-export build_file_context_and_attachments for backward compatibility
+from code_puppy.api.ws.attachments import ( # noqa: F401
+ build_file_context_and_attachments,
+)
+
+# Re-export connection_manager for backward compatibility
+from code_puppy.api.ws.connection_manager import ( # noqa: F401
+ WebSocketConnectionManager,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def setup_websocket(app: FastAPI) -> None:
+ """Setup WebSocket endpoints for the application.
+
+ Registers all WebSocket routes by delegating to specialized handler modules.
+ """
+ register_events_endpoint(app)
+ register_chat_endpoint(app)
+ register_terminal_endpoint(app)
+ register_health_endpoint(app)
+ register_sessions_endpoint(app)
+
+ logger.debug("All WebSocket endpoints registered")
From 45c1b50e7dbf4fe254b45e1fb8aa52cbd56d0b0e Mon Sep 17 00:00:00 2001
From: shravya maddipudi
Date: Thu, 28 May 2026 18:38:32 -0500
Subject: [PATCH 05/29] feat(ws): migrate modular websocket handlers and
runtime management
---
code_puppy/api/ws/__init__.py | 30 +
code_puppy/api/ws/attachments.py | 93 +++
code_puppy/api/ws/background_save.py | 244 +++++++
code_puppy/api/ws/connection_manager.py | 55 ++
code_puppy/api/ws/events_handler.py | 53 ++
code_puppy/api/ws/health_handler.py | 22 +
code_puppy/api/ws/runtime/__init__.py | 14 +
code_puppy/api/ws/runtime/session_runtime.py | 68 ++
.../api/ws/runtime/session_runtime_manager.py | 150 +++++
code_puppy/api/ws/schemas.py | 603 ++++++++++++++++++
code_puppy/api/ws/sessions_handler.py | 69 ++
code_puppy/api/ws/terminal_handler.py | 141 ++++
12 files changed, 1542 insertions(+)
create mode 100644 code_puppy/api/ws/__init__.py
create mode 100644 code_puppy/api/ws/attachments.py
create mode 100644 code_puppy/api/ws/background_save.py
create mode 100644 code_puppy/api/ws/connection_manager.py
create mode 100644 code_puppy/api/ws/events_handler.py
create mode 100644 code_puppy/api/ws/health_handler.py
create mode 100644 code_puppy/api/ws/runtime/__init__.py
create mode 100644 code_puppy/api/ws/runtime/session_runtime.py
create mode 100644 code_puppy/api/ws/runtime/session_runtime_manager.py
create mode 100644 code_puppy/api/ws/schemas.py
create mode 100644 code_puppy/api/ws/sessions_handler.py
create mode 100644 code_puppy/api/ws/terminal_handler.py
diff --git a/code_puppy/api/ws/__init__.py b/code_puppy/api/ws/__init__.py
new file mode 100644
index 000000000..1649afd36
--- /dev/null
+++ b/code_puppy/api/ws/__init__.py
@@ -0,0 +1,30 @@
+"""WebSocket endpoint handlers package.
+
+Each handler module exposes a `register_*_endpoint(app)` function
+that attaches one WebSocket route to the FastAPI application.
+
+Modules:
+ - chat_handler: Interactive chat with the Code Puppy agent (/ws/chat)
+ - events_handler: Server-sent events stream (/ws/events)
+ - terminal_handler: Interactive PTY terminal (/ws/terminal)
+ - health_handler: Simple health check (/ws/health)
+ - sessions_handler: Real-time session monitoring (/ws/sessions)
+ - connection_manager: Singleton for broadcasting session updates
+ - attachments: File attachment processing utilities
+"""
+
+from code_puppy.api.ws.chat_handler import register_chat_endpoint
+from code_puppy.api.ws.connection_manager import connection_manager
+from code_puppy.api.ws.events_handler import register_events_endpoint
+from code_puppy.api.ws.health_handler import register_health_endpoint
+from code_puppy.api.ws.sessions_handler import register_sessions_endpoint
+from code_puppy.api.ws.terminal_handler import register_terminal_endpoint
+
+__all__ = [
+ "register_chat_endpoint",
+ "register_events_endpoint",
+ "register_terminal_endpoint",
+ "register_health_endpoint",
+ "register_sessions_endpoint",
+ "connection_manager",
+]
diff --git a/code_puppy/api/ws/attachments.py b/code_puppy/api/ws/attachments.py
new file mode 100644
index 000000000..f2db90726
--- /dev/null
+++ b/code_puppy/api/ws/attachments.py
@@ -0,0 +1,93 @@
+"""File attachment processing utilities for WebSocket chat.
+
+Handles converting file attachment paths into either binary content
+(for images/PDFs) or text context (for code/text files).
+"""
+
+import logging
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+# Supported binary types (Claude's native attachments)
+BINARY_EXTENSIONS = {
+ ".png",
+ ".jpg",
+ ".jpeg",
+ ".gif",
+ ".webp",
+ ".pdf",
+ ".bmp",
+ ".tiff",
+}
+
+
+def build_file_context_and_attachments(msg: dict):
+ """Convert attachment paths into either binary attachments OR text context.
+
+ For images/PDFs: Send as BinaryContent (Claude's native attachment support)
+ For text files: Read and prepend to message so Code Puppy can analyze them
+
+ Returns: (text_context, binary_attachments)
+ text_context: String to prepend to the message, or empty string
+ binary_attachments: List of BinaryContent for images/PDFs
+ """
+ attachment_paths = msg.get("attachments") or []
+
+ if not attachment_paths:
+ return "", []
+
+ text_context_parts = []
+ binary_attachments = []
+
+ for raw_path in attachment_paths:
+ if not isinstance(raw_path, str) or not raw_path.strip():
+ continue
+
+ try:
+ file_path = Path(raw_path)
+
+ if not file_path.exists():
+ logger.warning("Attachment file not found: %s", raw_path)
+ continue
+
+ ext = file_path.suffix.lower()
+
+ if ext in BINARY_EXTENSIONS:
+ try:
+ from pydantic_ai import BinaryContent
+
+ from code_puppy.command_line.attachments import (
+ _determine_media_type,
+ _load_binary,
+ )
+
+ data = _load_binary(file_path)
+ media_type = _determine_media_type(file_path)
+ binary_attachments.append(
+ BinaryContent(data=data, media_type=media_type)
+ )
+ logger.debug("Loaded binary attachment: %s", file_path.name)
+ except Exception as e:
+ logger.warning(
+ f"Failed to load binary attachment '{raw_path}': {e}"
+ )
+ else:
+ try:
+ content = file_path.read_text(encoding="utf-8", errors="ignore")
+ text_context_parts.append(
+ f"\n\n--- File: {file_path.name} ({raw_path}) ---\n"
+ f"{content}\n"
+ f"--- End of {file_path.name} ---\n"
+ )
+ logger.debug(
+ f"Loaded text file: {file_path.name} ({len(content)} chars)"
+ )
+ except Exception as e:
+ logger.warning("Failed to read text file '%s': %s", raw_path, e)
+
+ except Exception as e:
+ logger.warning("Error processing attachment '%s': %s", raw_path, e)
+
+ text_context = "".join(text_context_parts)
+ return text_context, binary_attachments
diff --git a/code_puppy/api/ws/background_save.py b/code_puppy/api/ws/background_save.py
new file mode 100644
index 000000000..4b1a1c6ad
--- /dev/null
+++ b/code_puppy/api/ws/background_save.py
@@ -0,0 +1,244 @@
+"""Background agent result persistence for WebSocket chat sessions.
+
+When a user switches sessions or disconnects while the agent is running, the
+agent is allowed to finish in the background. This module provides a single
+reusable coroutine — ``save_agent_result_in_background`` — that:
+
+1. Awaits the agent task.
+2. Syncs completed messages onto the agent instance.
+3. Checks the session still exists (guards against resurrecting deleted sessions).
+4. Persists the result to SQLite via ``write_turn_to_sqlite``.
+
+All three original call-sites (switch, disconnect, runtime-error) are
+collapsed here, eliminating ~363 lines of duplicated closure code.
+
+Usage
+-----
+::
+
+ from code_puppy.api.ws.background_save import (
+ fire_and_track,
+ save_agent_result_in_background,
+ )
+
+ fire_and_track(
+ save_agent_result_in_background(
+ agent_task=active_agent_task,
+ session_id=session_id,
+ ctx=ctx,
+ agent=agent,
+ agent_name=agent_name,
+ model_name=model_name,
+ title=session_title,
+ working_directory=session_working_directory,
+ pinned=session_pinned,
+ label="switch", # "switch" | "disconnect" | "runtime"
+ )
+ )
+"""
+
+from __future__ import annotations
+
+import asyncio
+import datetime
+import logging
+from typing import Any
+
+from code_puppy.api.db.queries import session_exists, write_turn_to_sqlite
+
+logger = logging.getLogger(__name__)
+
+# ---------------------------------------------------------------------------
+# Task registry — prevents fire-and-forget tasks from being GC'd mid-flight.
+# ---------------------------------------------------------------------------
+
+_BACKGROUND_TASKS: set[asyncio.Task] = set()
+
+
+def fire_and_track(coro: Any) -> asyncio.Task:
+ """Spawn *coro* as a tracked background Task.
+
+ The task is kept alive in ``_BACKGROUND_TASKS`` until it completes,
+ preventing it from being garbage-collected before it finishes.
+ """
+ task = asyncio.create_task(coro)
+ _BACKGROUND_TASKS.add(task)
+ task.add_done_callback(_BACKGROUND_TASKS.discard)
+ return task
+
+
+# ---------------------------------------------------------------------------
+# Core background-save coroutine
+# ---------------------------------------------------------------------------
+
+
+async def save_agent_result_in_background(
+ *,
+ agent_task: asyncio.Task | None,
+ session_id: str,
+ ctx: Any, # SessionContext | None
+ agent: Any, # code_puppy Agent instance
+ agent_name: str,
+ model_name: str,
+ title: str,
+ working_directory: str,
+ pinned: bool,
+ label: str = "bg",
+) -> None:
+ """Await *agent_task*, then persist the result to SQLite.
+
+ Parameters
+ ----------
+ agent_task:
+ The running ``run_with_mcp`` asyncio Task. ``None`` is a safe no-op.
+ session_id:
+ The session whose messages should be persisted.
+ ctx:
+ The ``SessionContext`` snapshot at time of the switch/disconnect.
+ May be ``None``; used for ``created_at`` and token estimation.
+ agent:
+ Agent instance attached to the session.
+ agent_name / model_name / title / working_directory / pinned:
+ Session metadata — snapshotted by the caller before state changes.
+ label:
+ Short string used in log messages: ``"switch"``, ``"disconnect"``,
+ or ``"runtime"``.
+ """
+ if agent_task is None:
+ return
+
+ try:
+ # ------------------------------------------------------------------
+ # 1. Await the in-flight agent task.
+ # ------------------------------------------------------------------
+ try:
+ result = await agent_task
+ except asyncio.CancelledError:
+ logger.debug("[BG:%s] Agent task was cancelled (%s)", session_id, label)
+ return
+ except Exception as exc:
+ logger.warning("[BG:%s] Agent task failed (%s): %s", session_id, label, exc)
+ return
+
+ if result is None:
+ return
+
+ # ------------------------------------------------------------------
+ # 2. Sync completed messages back onto the agent instance.
+ # ------------------------------------------------------------------
+ try:
+ all_msgs = result.all_messages()
+ agent.set_message_history(all_msgs)
+ except Exception as exc:
+ logger.warning(
+ "[BG:%s] Could not extract messages from result (%s): %s",
+ session_id,
+ label,
+ exc,
+ )
+ # Don't bail — get_message_history() may still hold a partial history.
+
+ history = agent.get_message_history()
+ if not history:
+ logger.debug(
+ "[BG:%s] Empty history after completion (%s) — nothing to save",
+ session_id,
+ label,
+ )
+ return
+
+ # ------------------------------------------------------------------
+ # 3. Guard: skip write if the session was deleted between task start
+ # and completion (avoids resurrecting a deliberately deleted session).
+ # ------------------------------------------------------------------
+ try:
+ if not await session_exists(session_id):
+ logger.info(
+ "[BG:%s] Session deleted before background save (%s) — skipping",
+ session_id,
+ label,
+ )
+ return
+ except Exception as exc:
+ logger.warning(
+ "[BG:%s] session_exists check failed (%s): %s — proceeding anyway",
+ session_id,
+ label,
+ exc,
+ )
+
+ # ------------------------------------------------------------------
+ # 4. Build enhanced history wrappers.
+ # Pre-wrapped dicts (legacy or in-memory system-message injections)
+ # pass through unchanged; bare ModelMessage objects get wrapped.
+ # ------------------------------------------------------------------
+ now_iso = datetime.datetime.now(datetime.timezone.utc).isoformat()
+
+ enhanced_history: list[dict[str, Any]] = []
+ for msg in history:
+ if isinstance(msg, dict) and "msg" in msg:
+ enhanced_history.append(msg)
+ else:
+ enhanced_history.append(
+ {
+ "msg": msg,
+ "agent": agent_name,
+ "model": model_name,
+ "ts": now_iso,
+ }
+ )
+
+ # ------------------------------------------------------------------
+ # 5. Compute token count from the completed history.
+ # The three old closures hard-coded 0; we compute it properly here.
+ # ------------------------------------------------------------------
+ total_tokens = 0
+ try:
+ for item in enhanced_history:
+ msg_obj = (
+ item["msg"] if isinstance(item, dict) and "msg" in item else item
+ )
+ total_tokens += agent.estimate_tokens_for_message(msg_obj)
+ except Exception:
+ total_tokens = 0
+
+ # ------------------------------------------------------------------
+ # 6. Resolve created_at safely — ctx may lack the attribute in tests.
+ # ------------------------------------------------------------------
+ ctx_created_at = getattr(ctx, "created_at", None)
+ created_at = (
+ ctx_created_at.isoformat() if ctx_created_at is not None else now_iso
+ )
+
+ # ------------------------------------------------------------------
+ # 7. Persist to SQLite.
+ # ------------------------------------------------------------------
+ await write_turn_to_sqlite(
+ session_id=session_id,
+ enhanced_history=enhanced_history,
+ title=title,
+ working_directory=working_directory,
+ pinned=pinned,
+ agent_name=agent_name,
+ model_name=model_name,
+ total_tokens=total_tokens,
+ updated_at=now_iso,
+ created_at=created_at,
+ ctx=ctx,
+ )
+ logger.info(
+ "[BG:%s] Background save complete (%s): %d messages, %d tokens",
+ session_id,
+ label,
+ len(enhanced_history),
+ total_tokens,
+ )
+
+ except Exception as exc:
+ logger.error(
+ "[BG:%s] Background save failed (%s): %s",
+ session_id,
+ label,
+ exc,
+ exc_info=True,
+ )
diff --git a/code_puppy/api/ws/connection_manager.py b/code_puppy/api/ws/connection_manager.py
new file mode 100644
index 000000000..eec49804d
--- /dev/null
+++ b/code_puppy/api/ws/connection_manager.py
@@ -0,0 +1,55 @@
+"""WebSocket connection manager for broadcasting session updates.
+
+This is a singleton that tracks connected WebSocket clients monitoring
+session state changes.
+"""
+
+import logging
+
+from fastapi import WebSocket
+
+logger = logging.getLogger(__name__)
+
+
+class WebSocketConnectionManager:
+ """Manages WebSocket connections for broadcasting session updates."""
+
+ def __init__(self):
+ self.session_connections: list[WebSocket] = []
+
+ async def connect_session_client(self, websocket: WebSocket):
+ """Connect a session monitoring client."""
+ self.session_connections.append(websocket)
+ logger.debug(
+ f"Session client connected. Total: {len(self.session_connections)}"
+ )
+
+ def disconnect_session_client(self, websocket: WebSocket):
+ """Disconnect a session monitoring client."""
+ if websocket in self.session_connections:
+ self.session_connections.remove(websocket)
+ logger.debug(
+ f"Session client disconnected. Total: {len(self.session_connections)}"
+ )
+
+ async def broadcast_session_update(self, session_data: dict):
+ """Broadcast session update to all connected session clients."""
+ if not self.session_connections:
+ return
+
+ connections = self.session_connections.copy()
+ disconnected = []
+
+ for conn in connections:
+ try:
+ await conn.send_json({"type": "session_update", "data": session_data})
+ except Exception as e:
+ logger.warning("Failed to send session update: %s", e)
+ disconnected.append(conn)
+
+ for conn in disconnected:
+ self.disconnect_session_client(conn)
+
+
+# Global singleton instance
+connection_manager = WebSocketConnectionManager()
diff --git a/code_puppy/api/ws/events_handler.py b/code_puppy/api/ws/events_handler.py
new file mode 100644
index 000000000..b9bf647ec
--- /dev/null
+++ b/code_puppy/api/ws/events_handler.py
@@ -0,0 +1,53 @@
+"""WebSocket endpoint for server-sent events streaming."""
+
+import asyncio
+import logging
+
+from fastapi import FastAPI, WebSocket, WebSocketDisconnect
+
+logger = logging.getLogger(__name__)
+
+
+def register_events_endpoint(app: FastAPI) -> None:
+ """Register the /ws/events WebSocket endpoint."""
+
+ @app.websocket("/ws/events")
+ async def websocket_events(
+ websocket: WebSocket, session_id: str | None = None
+ ) -> None:
+ """Stream real-time events to connected clients.
+
+ Query Parameters:
+ session_id: Optional session ID. If provided, only events for that
+ session are streamed.
+ """
+ await websocket.accept()
+ logger.debug("Events WebSocket client connected (session_id=%s)", session_id)
+
+ from code_puppy.plugins.frontend_emitter.emitter import (
+ get_recent_events,
+ subscribe,
+ unsubscribe,
+ )
+
+ event_queue = subscribe(session_id=session_id)
+
+ try:
+ for event in get_recent_events(session_id=session_id):
+ await websocket.send_json(event)
+
+ while True:
+ try:
+ event = await asyncio.wait_for(event_queue.get(), timeout=30.0)
+ await websocket.send_json(event)
+ except asyncio.TimeoutError:
+ try:
+ await websocket.send_json({"type": "ping"})
+ except Exception:
+ break
+ except WebSocketDisconnect:
+ logger.debug("Events WebSocket client disconnected")
+ except Exception as e:
+ logger.error("Events WebSocket error: %s", e)
+ finally:
+ unsubscribe(event_queue)
diff --git a/code_puppy/api/ws/health_handler.py b/code_puppy/api/ws/health_handler.py
new file mode 100644
index 000000000..832f45e95
--- /dev/null
+++ b/code_puppy/api/ws/health_handler.py
@@ -0,0 +1,22 @@
+"""WebSocket endpoint for health checks."""
+
+import logging
+
+from fastapi import FastAPI, WebSocket, WebSocketDisconnect
+
+logger = logging.getLogger(__name__)
+
+
+def register_health_endpoint(app: FastAPI) -> None:
+ """Register the /ws/health WebSocket endpoint."""
+
+ @app.websocket("/ws/health")
+ async def websocket_health(websocket: WebSocket) -> None:
+ """Simple WebSocket health check - echoes messages back."""
+ await websocket.accept()
+ try:
+ while True:
+ data = await websocket.receive_text()
+ await websocket.send_text(f"echo: {data}")
+ except WebSocketDisconnect:
+ pass
diff --git a/code_puppy/api/ws/runtime/__init__.py b/code_puppy/api/ws/runtime/__init__.py
new file mode 100644
index 000000000..750f74d72
--- /dev/null
+++ b/code_puppy/api/ws/runtime/__init__.py
@@ -0,0 +1,14 @@
+"""Runtime scaffolding for multi-session chat.
+
+These modules are intentionally lightweight and are not yet wired into
+`/ws/chat` or the planned `/ws/chat_mux` endpoint.
+
+They provide an async-friendly session runtime object (cancellation, active
+streaming task handle, etc.) and a manager that keeps runtimes keyed by
+session_id.
+"""
+
+from .session_runtime import SessionRuntime
+from .session_runtime_manager import SessionRuntimeManager
+
+__all__ = ["SessionRuntime", "SessionRuntimeManager"]
diff --git a/code_puppy/api/ws/runtime/session_runtime.py b/code_puppy/api/ws/runtime/session_runtime.py
new file mode 100644
index 000000000..a6fda2741
--- /dev/null
+++ b/code_puppy/api/ws/runtime/session_runtime.py
@@ -0,0 +1,68 @@
+from __future__ import annotations
+
+import asyncio
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+from typing import Any
+
+from code_puppy.api.session_context import SessionContext
+
+
+@dataclass(slots=True)
+class SessionRuntime:
+ """Async runtime state for a single session.
+
+ This complements :class:`~code_puppy.api.session_context.SessionContext`.
+
+ SessionContext stores durable-ish session state (agent, history, metadata).
+ SessionRuntime stores ephemeral runtime state (active stream task,
+ cancellation token/event, etc.).
+
+ NOTE: This is scaffolding for chat mux and is not yet integrated into
+ websocket handlers.
+ """
+
+ session_id: str
+ ctx: SessionContext
+
+ created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+ last_used_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+
+ # When set, any active streaming loop should stop ASAP.
+ cancel_event: asyncio.Event = field(default_factory=asyncio.Event)
+
+ # Track at most one active streaming task at a time for now.
+ _active_task: asyncio.Task[Any] | None = None
+
+ # Serialise runtime-level mutations.
+ _lock: asyncio.Lock = field(default_factory=asyncio.Lock, repr=False)
+
+ async def set_active_task(self, task: asyncio.Task[Any] | None) -> None:
+ """Set (or clear) the active streaming task for this session."""
+ async with self._lock:
+ self._active_task = task
+ self.last_used_at = datetime.now(timezone.utc)
+
+ async def get_active_task(self) -> asyncio.Task[Any] | None:
+ async with self._lock:
+ return self._active_task
+
+ async def cancel(self) -> None:
+ """Cancel the active streaming task (if any) and set cancel_event."""
+ self.cancel_event.set()
+ async with self._lock:
+ task = self._active_task
+
+ if task and not task.done():
+ task.cancel()
+
+ async def reset_cancel(self) -> None:
+ """Clear cancel state for the next turn."""
+ # asyncio.Event has no clear() in older Python; but 3.11+ has.
+ # This project supports 3.11+, so use clear().
+ self.cancel_event.clear()
+
+ async def touch(self) -> None:
+ """Update last_used_at."""
+ async with self._lock:
+ self.last_used_at = datetime.now(timezone.utc)
diff --git a/code_puppy/api/ws/runtime/session_runtime_manager.py b/code_puppy/api/ws/runtime/session_runtime_manager.py
new file mode 100644
index 000000000..82a8252d3
--- /dev/null
+++ b/code_puppy/api/ws/runtime/session_runtime_manager.py
@@ -0,0 +1,150 @@
+from __future__ import annotations
+
+import asyncio
+import logging
+from collections.abc import Callable
+from dataclasses import dataclass
+from datetime import datetime, timedelta, timezone
+
+from code_puppy.api.session_context import SessionContext, session_manager
+
+from .session_runtime import SessionRuntime
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True, slots=True)
+class RuntimeCleanupResult:
+ removed_session_ids: tuple[str, ...]
+
+
+class SessionRuntimeManager:
+ """Registry for :class:`~code_puppy.api.ws.runtime.SessionRuntime`.
+
+ This is designed for the multiplexed chat websocket, where the
+ server keeps multiple sessions "hot" at once.
+ """
+
+ def __init__(self, now: Callable[[], datetime] | None = None) -> None:
+ self._now = now or (lambda: datetime.now(timezone.utc))
+ self._runtimes: dict[str, SessionRuntime] = {}
+ self._lock = asyncio.Lock()
+
+ async def get_runtime(self, session_id: str) -> SessionRuntime | None:
+ async with self._lock:
+ return self._runtimes.get(session_id)
+
+ async def ensure_runtime(
+ self,
+ session_id: str,
+ *,
+ agent_name: str = "code-puppy",
+ model_name: str | None = None,
+ working_directory: str = "",
+ load_if_exists: bool = True,
+ ) -> SessionRuntime:
+ """Get or create a runtime for *session_id*.
+
+ This bridges to the existing :mod:`code_puppy.api.session_context` layer:
+ - If an in-memory SessionContext exists, we reuse it.
+ - Else, we optionally load from disk via SessionManager.load_session.
+ - Else, we create a new SessionContext.
+
+ Args:
+ load_if_exists: If True, attempt to load persisted session state.
+
+ Returns:
+ SessionRuntime instance.
+ """
+ async with self._lock:
+ existing = self._runtimes.get(session_id)
+ if existing is not None:
+ existing.last_used_at = self._now()
+ return existing
+
+ ctx = await session_manager.get_session(session_id)
+ if ctx is None and load_if_exists:
+ try:
+ ctx = await session_manager.load_session(session_id)
+ except Exception:
+ logger.debug(
+ "Failed to load session %s from disk", session_id, exc_info=True
+ )
+ ctx = None
+
+ if ctx is None:
+ ctx = await session_manager.create_session(
+ session_id,
+ agent_name=agent_name,
+ model_name=model_name,
+ working_directory=working_directory,
+ )
+
+ rt = SessionRuntime(session_id=session_id, ctx=ctx)
+ rt.last_used_at = self._now()
+ self._runtimes[session_id] = rt
+ return rt
+
+ async def cancel_session(self, session_id: str) -> bool:
+ """Cancel the active task for *session_id*.
+
+ Returns:
+ True if a runtime existed and cancellation was requested.
+ """
+ rt = await self.get_runtime(session_id)
+ if rt is None:
+ return False
+ await rt.cancel()
+ return True
+
+ async def remove_runtime(self, session_id: str) -> bool:
+ """Remove runtime from the manager.
+
+ This does not destroy the underlying SessionContext (that remains in the
+ existing SessionManager registry).
+ """
+ async with self._lock:
+ removed = self._runtimes.pop(session_id, None)
+ return removed is not None
+
+ async def cleanup_idle(self, *, max_idle: timedelta) -> RuntimeCleanupResult:
+ """Remove runtimes that have been idle longer than *max_idle*."""
+ now = self._now()
+ removed: list[str] = []
+
+ async with self._lock:
+ for session_id, rt in list(self._runtimes.items()):
+ if now - rt.last_used_at > max_idle:
+ removed.append(session_id)
+ self._runtimes.pop(session_id, None)
+
+ if removed:
+ logger.info("Cleaned up %d idle runtimes", len(removed))
+
+ return RuntimeCleanupResult(removed_session_ids=tuple(removed))
+
+ async def get_session_context(self, session_id: str) -> SessionContext | None:
+ rt = await self.get_runtime(session_id)
+ return rt.ctx if rt else None
+
+ async def active_task_count(self) -> int:
+ """Count runtimes with active tasks (for debugging/tests)."""
+ async with self._lock:
+ rts = list(self._runtimes.values())
+
+ count = 0
+ for rt in rts:
+ task = await rt.get_active_task()
+ if task is not None and not task.done():
+ count += 1
+ return count
+
+
+_manager_instance: SessionRuntimeManager | None = None
+
+
+def get_runtime_manager() -> SessionRuntimeManager:
+ global _manager_instance
+ if _manager_instance is None:
+ _manager_instance = SessionRuntimeManager()
+ return _manager_instance
diff --git a/code_puppy/api/ws/schemas.py b/code_puppy/api/ws/schemas.py
new file mode 100644
index 000000000..05e56528d
--- /dev/null
+++ b/code_puppy/api/ws/schemas.py
@@ -0,0 +1,603 @@
+"""WebSocket protocol schemas — single source of truth for the CP wire protocol.
+
+Every client→server and server→client message is defined here as a Pydantic
+model with ``Literal`` type discriminators so the framework can parse raw JSON
+into the correct model automatically via ``ClientMessage`` / ``ServerMessage``
+discriminated unions.
+
+Serialisation convention:
+ ``.model_dump(exclude_none=True)`` — optional fields that are ``None``
+ are omitted from the wire representation to keep payloads lean.
+
+All field names and types are derived from the *actual* ``send_json`` /
+``safe_send_json`` calls in ``chat_handler.py`` and ``permissions.py``.
+"""
+
+from __future__ import annotations
+
+from enum import Enum
+from typing import Annotated, Any, Dict, List, Literal, Optional, Union
+
+from pydantic import BaseModel, Discriminator, Tag
+
+# ── Protocol version ────────────────────────────────────────────────────────
+PROTOCOL_VERSION = "1.0.0"
+
+
+# ── Enums ────────────────────────────────────────────────────────────────────
+
+
+class ClientMessageType(str, Enum):
+ """All ``type`` values a client may send over the WebSocket."""
+
+ MESSAGE = "message"
+ SWITCH_AGENT = "switch_agent"
+ SWITCH_MODEL = "switch_model"
+ SWITCH_SESSION = "switch_session"
+ SET_WORKING_DIRECTORY = "set_working_directory"
+ UPDATE_SESSION_META = "update_session_meta"
+ GET_CONFIG = "get_config"
+ SET_CONFIG = "set_config"
+ COMMAND = "command"
+ CANCEL = "cancel"
+ PERMISSION_RESPONSE = "permission_response"
+
+
+class ServerMessageType(str, Enum):
+ """All ``type`` values the server may send over the WebSocket."""
+
+ SYSTEM = "system"
+ SESSION_RESTORED = "session_restored"
+ SESSION_SWITCHED = "session_switched"
+ WORKING_DIRECTORY_CHANGED = "working_directory_changed"
+ SESSION_META_UPDATED = "session_meta_updated"
+ CONFIG_VALUE = "config_value"
+ COMMAND_RESULT = "command_result"
+ STATUS = "status"
+ USER_MESSAGE = "user_message"
+ ASSISTANT_MESSAGE_START = "assistant_message_start"
+ ASSISTANT_MESSAGE_DELTA = "assistant_message_delta"
+ ASSISTANT_MESSAGE_END = "assistant_message_end"
+ TOOL_CALL = "tool_call"
+ TOOL_RESULT = "tool_result"
+ TOOL_RETURN = "tool_return"
+ AGENT_INVOKED = "agent_invoked"
+ RESPONSE = "response"
+ STREAM_END = "stream_end"
+ ERROR = "error"
+ PERMISSION_REQUEST = "permission_request"
+ CANCELLED = "cancelled"
+
+
+class PartType(str, Enum):
+ """Streaming part types used in ``assistant_message_start``."""
+
+ TEXT = "text"
+ THINKING = "thinking"
+ TOOL_CALL = "tool_call"
+
+
+class ErrorType(str, Enum):
+ """Error categories returned by ``parse_api_error`` / ``error_parser``."""
+
+ RATE_LIMIT = "rate_limit"
+ SERVER_ERROR = "server_error"
+ AUTH_ERROR = "auth_error"
+ QUOTA_EXCEEDED = "quota_exceeded"
+ CONTENT_BLOCKED = "content_blocked"
+ TOOL_HISTORY_ERROR = "tool_history_error"
+ NETWORK_ERROR = "network_error"
+ MODEL_NOT_FOUND = "model_not_found"
+ CLAUDE_TEMPERATURE_ERROR = "claude_temperature_error"
+ UNKNOWN = "unknown"
+ UNKNOWN_ERROR = "unknown_error"
+
+
+# ── Base ─────────────────────────────────────────────────────────────────────
+
+
+class _BaseMessage(BaseModel):
+ """Shared config for all protocol messages."""
+
+ model_config = {"extra": "allow"}
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# CLIENT → SERVER (11 message types)
+# ═══════════════════════════════════════════════════════════════════════════════
+
+
+class ClientMessageMessage(_BaseMessage):
+ """User chat message.
+
+ Sent when the user types a message in the chat input.
+ ``content`` is the message text. Optional ``model`` overrides the
+ session model for this single turn. ``model_settings`` passes
+ per-request knobs (e.g. reasoning_effort). ``attachments`` is a
+ list of file paths to include.
+ """
+
+ type: Literal["message"] = "message"
+ content: str
+ model: Optional[str] = None
+ model_settings: Optional[Dict[str, Any]] = None
+ attachments: Optional[List[str]] = None
+
+
+class ClientSwitchAgent(_BaseMessage):
+ """Request to switch the active agent.
+
+ ``agent_name`` is the target agent identifier (e.g. ``"code-puppy"``).
+ """
+
+ type: Literal["switch_agent"] = "switch_agent"
+ agent_name: str
+
+
+class ClientSwitchModel(_BaseMessage):
+ """Request to switch the active model.
+
+ The server accepts either ``model_name`` or ``model`` for backwards
+ compatibility — see ``msg.get("model_name") or msg.get("model")``.
+ """
+
+ type: Literal["switch_model"] = "switch_model"
+ model_name: Optional[str] = None
+ model: Optional[str] = None
+
+
+class ClientSwitchSession(_BaseMessage):
+ """Request to switch to a different chat session.
+
+ ``session_id`` is the target session identifier.
+ """
+
+ type: Literal["switch_session"] = "switch_session"
+ session_id: str
+
+
+class ClientSetWorkingDirectory(_BaseMessage):
+ """Set the working directory for the current session.
+
+ ``directory`` is an absolute filesystem path.
+ """
+
+ type: Literal["set_working_directory"] = "set_working_directory"
+ directory: str
+
+
+class ClientUpdateSessionMeta(_BaseMessage):
+ """Update session metadata (title, pinned state).
+
+ Both fields are optional — only supplied fields are updated.
+ """
+
+ type: Literal["update_session_meta"] = "update_session_meta"
+ title: Optional[str] = None
+ pinned: Optional[bool] = None
+
+
+class ClientGetConfig(_BaseMessage):
+ """Read a configuration value by key."""
+
+ type: Literal["get_config"] = "get_config"
+ key: str
+
+
+class ClientSetConfig(_BaseMessage):
+ """Write a configuration value."""
+
+ type: Literal["set_config"] = "set_config"
+ key: str
+ value: Any
+
+
+class ClientCommand(_BaseMessage):
+ """Execute a slash command (e.g. ``/help``)."""
+
+ type: Literal["command"] = "command"
+ command: str
+
+
+class ClientCancel(_BaseMessage):
+ """Cancel / interrupt the current streaming response or agent run."""
+
+ type: Literal["cancel"] = "cancel"
+
+
+class ClientPermissionResponse(_BaseMessage):
+ """User's response to a permission request.
+
+ ``approved`` indicates whether the user granted the permission.
+ ``remember`` is reserved for future "always allow" support.
+ """
+
+ type: Literal["permission_response"] = "permission_response"
+ request_id: str
+ approved: bool
+ remember: Optional[bool] = None
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# SERVER → CLIENT (21 message types)
+# ═══════════════════════════════════════════════════════════════════════════════
+
+
+class ServerSystem(_BaseMessage):
+ """System / informational message.
+
+ Sent on initial connection (welcome), agent/model switches, and
+ replayed directory banners on session restore.
+ """
+
+ type: Literal["system"] = "system"
+ content: str
+ session_id: str
+ agent_name: Optional[str] = None
+ model_name: Optional[str] = None
+ resumed: Optional[bool] = None
+ protocol_version: Optional[str] = None
+
+
+class ServerSessionRestored(_BaseMessage):
+ """Confirmation that an existing session was restored from storage.
+
+ ``message_count`` reflects how many messages were loaded into the
+ agent's history. ``ui_metadata`` carries replayed UI-only records.
+ """
+
+ type: Literal["session_restored"] = "session_restored"
+ session_id: str
+ message_count: int
+ title: Optional[str] = None
+ ui_metadata: Optional[List[Any]] = None
+
+
+class ServerSessionSwitched(_BaseMessage):
+ """Confirmation that the active session was switched.
+
+ ``created`` is ``True`` when the target session did not exist and
+ was freshly created.
+ """
+
+ type: Literal["session_switched"] = "session_switched"
+ session_id: str
+ message_count: int
+ title: Optional[str] = None
+ working_directory: Optional[str] = None
+ created: bool
+ agent_name: Optional[str] = None
+ model_name: Optional[str] = None
+
+
+class ServerWorkingDirectoryChanged(_BaseMessage):
+ """Result of a ``set_working_directory`` request.
+
+ ``success`` is ``False`` when the directory does not exist.
+ ``unchanged`` is ``True`` when the directory matches the current one.
+ """
+
+ type: Literal["working_directory_changed"] = "working_directory_changed"
+ directory: str
+ success: bool
+ session_id: str
+ unchanged: Optional[bool] = None
+ error: Optional[str] = None
+
+
+class ServerSessionMetaUpdated(_BaseMessage):
+ """Acknowledgement that session metadata was updated."""
+
+ type: Literal["session_meta_updated"] = "session_meta_updated"
+ session_id: str
+ pinned: Optional[bool] = None
+ title: Optional[str] = None
+
+
+class ServerConfigValue(_BaseMessage):
+ """Response to ``get_config`` or ``set_config``.
+
+ ``success`` is present only on ``set_config`` responses.
+ """
+
+ type: Literal["config_value"] = "config_value"
+ key: str
+ value: Any
+ session_id: str
+ success: Optional[bool] = None
+
+
+class ServerCommandResult(_BaseMessage):
+ """Result of a slash command execution.
+
+ ``output`` contains rendered text (e.g. help output).
+ ``error`` is present when the command raised an exception.
+ """
+
+ type: Literal["command_result"] = "command_result"
+ command: str
+ success: bool
+ session_id: str
+ output: Optional[str] = None
+ messages: Optional[List[Any]] = None
+ result: Optional[str] = None
+ error: Optional[str] = None
+
+
+class ServerStatus(_BaseMessage):
+ """Transient status update (e.g. ``"cancelled"``, ``"thinking"``).
+
+ Sent in response to a ``cancel`` request or to indicate agent
+ activity (e.g. "thinking" with current agent/model context).
+ """
+
+ type: Literal["status"] = "status"
+ status: str
+ session_id: str
+ agent_name: Optional[str] = None
+ model_name: Optional[str] = None
+
+
+class ServerUserMessage(_BaseMessage):
+ """Echo of the user's chat message back to the client.
+
+ Sent immediately after receiving a ``message`` from the client so
+ the UI can render it before the assistant starts responding.
+ """
+
+ type: Literal["user_message"] = "user_message"
+ content: str
+ session_id: str
+
+
+class ServerAssistantMessageStart(_BaseMessage):
+ """Marks the beginning of a streaming assistant message part.
+
+ ``part_type`` is ``"text"`` or ``"thinking"``.
+ ``tool_name`` is set when the text part follows a tool call.
+ """
+
+ type: Literal["assistant_message_start"] = "assistant_message_start"
+ message_id: str
+ part_type: str
+ part_index: int
+ timestamp: float
+ session_id: str
+ agent_name: Optional[str] = None
+ model_name: Optional[str] = None
+ tool_name: Optional[str] = None
+
+
+class ServerAssistantMessageDelta(_BaseMessage):
+ """A streaming content chunk for an in-progress assistant message."""
+
+ type: Literal["assistant_message_delta"] = "assistant_message_delta"
+ message_id: str
+ content: str
+ part_index: int
+ session_id: str
+ agent_name: Optional[str] = None
+ model_name: Optional[str] = None
+ tool_name: Optional[str] = None
+
+
+class ServerAssistantMessageEnd(_BaseMessage):
+ """Marks the end of a streaming assistant message part.
+
+ ``full_content`` contains the complete accumulated text for the part.
+ """
+
+ type: Literal["assistant_message_end"] = "assistant_message_end"
+ message_id: str
+ part_index: int
+ full_content: str
+ timestamp: float
+ session_id: str
+ agent_name: Optional[str] = None
+ model_name: Optional[str] = None
+ tool_name: Optional[str] = None
+
+
+class ServerToolCall(_BaseMessage):
+ """Notification that a tool was invoked.
+
+ ``args`` contains the parsed tool arguments. ``tool_id`` correlates
+ with a later ``tool_result`` bearing the same ID. ``tool_group_id``
+ groups parallel tool calls in the same execution batch.
+ """
+
+ type: Literal["tool_call"] = "tool_call"
+ tool_id: str
+ tool_name: str
+ args: Any # dict after JSON parse, but may be raw str during streaming
+ timestamp: float
+ session_id: str
+ agent_name: Optional[str] = None
+ model_name: Optional[str] = None
+ tool_group_id: Optional[str] = None # Groups tools in same execution batch
+
+
+class ServerToolResult(_BaseMessage):
+ """Result of a tool execution.
+
+ ``tool_id`` matches the originating ``tool_call``. ``result`` is the
+ tool's return value (may be ``dict``, ``str``, or a status sentinel).
+ ``tool_group_id`` matches the originating ``tool_call`` group.
+ """
+
+ type: Literal["tool_result"] = "tool_result"
+ tool_name: str
+ result: Any
+ success: bool
+ duration_ms: float
+ timestamp: float
+ session_id: str
+ tool_id: Optional[str] = None
+ agent_name: Optional[str] = None
+ model_name: Optional[str] = None
+ tool_group_id: Optional[str] = None # Matches originating tool_call group
+
+
+class ServerToolReturn(_BaseMessage):
+ """Internal tool-return part tracked during streaming.
+
+ This message type represents a ``ToolReturnPart`` observed in the
+ streaming pipeline. It is stored internally for correlation but may
+ not always be sent directly to the client.
+ """
+
+ type: Literal["tool_return"] = "tool_return"
+ tool_call_id: Optional[str] = None
+ content: Optional[Any] = None
+ session_id: Optional[str] = None
+
+
+class ServerAgentInvoked(_BaseMessage):
+ """Notification that a sub-agent was invoked.
+
+ ``prompt_preview`` is a truncated preview of the prompt sent to the
+ sub-agent.
+ """
+
+ type: Literal["agent_invoked"] = "agent_invoked"
+ agent_name: str
+ prompt_preview: Optional[str] = None
+ timestamp: float
+ session_id: str
+
+
+class TokenUsage(BaseModel):
+ """Token usage statistics attached to ``response`` and ``stream_end``."""
+
+ input_tokens: Optional[int] = None
+ output_tokens: Optional[int] = None
+ total_tokens: Optional[int] = None
+ estimated: Optional[bool] = None
+
+
+class ServerResponse(_BaseMessage):
+ """Complete (non-streaming) response.
+
+ Sent when B1 streaming was *not* used (e.g. non-streaming models).
+ ``tokens`` carries optional token usage info.
+ """
+
+ type: Literal["response"] = "response"
+ content: str
+ done: bool
+ session_id: str
+ agent_name: Optional[str] = None
+ model_name: Optional[str] = None
+ tokens: Optional[Union[TokenUsage, Dict[str, Any]]] = None
+
+
+class ServerStreamEnd(_BaseMessage):
+ """End-of-stream marker for B1 streaming responses.
+
+ ``success`` is ``False`` when the stream ended due to an error.
+ ``total_length`` is the character count of the accumulated response.
+ """
+
+ type: Literal["stream_end"] = "stream_end"
+ success: bool
+ session_id: str
+ total_length: Optional[int] = None
+ agent_name: Optional[str] = None
+ model_name: Optional[str] = None
+ tokens: Optional[Union[TokenUsage, Dict[str, Any]]] = None
+
+
+class ServerError(_BaseMessage):
+ """Error message.
+
+ ``error_type`` classifies the error for frontend handling (e.g.
+ ``"rate_limit"``, ``"auth_error"``). ``action_required`` hints
+ that user intervention may be needed.
+ """
+
+ type: Literal["error"] = "error"
+ error: str
+ session_id: str
+ error_type: Optional[str] = None
+ technical_details: Optional[str] = None
+ action_required: Optional[bool] = None
+
+
+class ServerPermissionRequest(_BaseMessage):
+ """Asks the user to approve or deny a potentially dangerous operation.
+
+ Sent by the permission system before executing shell commands or
+ other privileged operations. The frontend should present an
+ approve/deny dialog and reply with ``permission_response``.
+ """
+
+ type: Literal["permission_request"] = "permission_request"
+ request_id: str
+ permission_type: str
+ title: str
+ description: str
+ details: Dict[str, Any]
+ session_id: str
+ timeout_seconds: int
+
+
+class ServerCancelled(_BaseMessage):
+ """Confirmation that the current agent run was cancelled.
+
+ Sent when the agent task is interrupted after a ``cancel`` request.
+ """
+
+ type: Literal["cancelled"] = "cancelled"
+ session_id: str
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# Discriminated unions
+# ═══════════════════════════════════════════════════════════════════════════════
+
+
+ClientMessage = Annotated[
+ Union[
+ Annotated[ClientMessageMessage, Tag("message")],
+ Annotated[ClientSwitchAgent, Tag("switch_agent")],
+ Annotated[ClientSwitchModel, Tag("switch_model")],
+ Annotated[ClientSwitchSession, Tag("switch_session")],
+ Annotated[ClientSetWorkingDirectory, Tag("set_working_directory")],
+ Annotated[ClientUpdateSessionMeta, Tag("update_session_meta")],
+ Annotated[ClientGetConfig, Tag("get_config")],
+ Annotated[ClientSetConfig, Tag("set_config")],
+ Annotated[ClientCommand, Tag("command")],
+ Annotated[ClientCancel, Tag("cancel")],
+ Annotated[ClientPermissionResponse, Tag("permission_response")],
+ ],
+ Discriminator("type"),
+]
+"""Discriminated union of all client→server message types."""
+
+ServerMessage = Annotated[
+ Union[
+ Annotated[ServerSystem, Tag("system")],
+ Annotated[ServerSessionRestored, Tag("session_restored")],
+ Annotated[ServerSessionSwitched, Tag("session_switched")],
+ Annotated[ServerWorkingDirectoryChanged, Tag("working_directory_changed")],
+ Annotated[ServerSessionMetaUpdated, Tag("session_meta_updated")],
+ Annotated[ServerConfigValue, Tag("config_value")],
+ Annotated[ServerCommandResult, Tag("command_result")],
+ Annotated[ServerStatus, Tag("status")],
+ Annotated[ServerUserMessage, Tag("user_message")],
+ Annotated[ServerAssistantMessageStart, Tag("assistant_message_start")],
+ Annotated[ServerAssistantMessageDelta, Tag("assistant_message_delta")],
+ Annotated[ServerAssistantMessageEnd, Tag("assistant_message_end")],
+ Annotated[ServerToolCall, Tag("tool_call")],
+ Annotated[ServerToolResult, Tag("tool_result")],
+ Annotated[ServerToolReturn, Tag("tool_return")],
+ Annotated[ServerAgentInvoked, Tag("agent_invoked")],
+ Annotated[ServerResponse, Tag("response")],
+ Annotated[ServerStreamEnd, Tag("stream_end")],
+ Annotated[ServerError, Tag("error")],
+ Annotated[ServerPermissionRequest, Tag("permission_request")],
+ Annotated[ServerCancelled, Tag("cancelled")],
+ ],
+ Discriminator("type"),
+]
+"""Discriminated union of all server→client message types."""
diff --git a/code_puppy/api/ws/sessions_handler.py b/code_puppy/api/ws/sessions_handler.py
new file mode 100644
index 000000000..f0719648c
--- /dev/null
+++ b/code_puppy/api/ws/sessions_handler.py
@@ -0,0 +1,69 @@
+"""WebSocket endpoint for real-time session monitoring."""
+
+import asyncio
+import logging
+
+from fastapi import FastAPI, WebSocket, WebSocketDisconnect
+
+from code_puppy.api.ws.connection_manager import connection_manager
+
+logger = logging.getLogger(__name__)
+
+
+def register_sessions_endpoint(app: FastAPI) -> None:
+ """Register the /ws/sessions WebSocket endpoint."""
+
+ @app.websocket("/ws/sessions")
+ async def websocket_sessions(websocket: WebSocket) -> None:
+ """WebSocket endpoint for real-time session updates.
+
+ Clients can connect to this endpoint to receive real-time notifications
+ when WebSocket sessions are created, updated, or deleted.
+
+ Messages sent to clients:
+ - {"type": "session_update", "data": WSSessionInfo}
+ - {"type": "ping"} (keepalive)
+ """
+ await websocket.accept()
+ logger.debug("Sessions monitoring WebSocket client connected")
+
+ try:
+ await connection_manager.connect_session_client(websocket)
+
+ # Send initial session list
+ try:
+ from code_puppy.api.routers.ws_sessions import list_ws_sessions
+
+ current_sessions = await list_ws_sessions()
+ await websocket.send_json(
+ {
+ "type": "initial_sessions",
+ "data": [session.model_dump() for session in current_sessions],
+ }
+ )
+ except Exception as e:
+ logger.error("Failed to send initial sessions: %s", e)
+ await websocket.send_json(
+ {"type": "error", "error": f"Failed to load sessions: {str(e)}"}
+ )
+
+ # Keep connection alive
+ while True:
+ try:
+ message = await asyncio.wait_for(
+ websocket.receive_json(), timeout=30.0
+ )
+ if message.get("type") == "ping":
+ await websocket.send_json({"type": "pong"})
+ except asyncio.TimeoutError:
+ try:
+ await websocket.send_json({"type": "ping"})
+ except Exception:
+ break
+
+ except WebSocketDisconnect:
+ logger.debug("Sessions monitoring WebSocket client disconnected")
+ except Exception as e:
+ logger.error("Sessions WebSocket error: %s", e)
+ finally:
+ connection_manager.disconnect_session_client(websocket)
diff --git a/code_puppy/api/ws/terminal_handler.py b/code_puppy/api/ws/terminal_handler.py
new file mode 100644
index 000000000..b32bff451
--- /dev/null
+++ b/code_puppy/api/ws/terminal_handler.py
@@ -0,0 +1,141 @@
+"""WebSocket endpoint for interactive PTY terminal sessions."""
+
+import asyncio
+import base64
+import logging
+import uuid
+
+from fastapi import FastAPI, WebSocket, WebSocketDisconnect
+
+logger = logging.getLogger(__name__)
+
+
+def register_terminal_endpoint(app: FastAPI) -> None:
+ """Register the /ws/terminal WebSocket endpoint."""
+
+ @app.websocket("/ws/terminal")
+ async def websocket_terminal(websocket: WebSocket) -> None:
+ """Interactive terminal WebSocket endpoint."""
+ await websocket.accept()
+ logger.debug("Terminal WebSocket client connected")
+
+ from code_puppy.api.pty_manager import get_pty_manager
+
+ manager = get_pty_manager()
+ session_id = str(uuid.uuid4())[:8]
+ session = None
+
+ loop = asyncio.get_running_loop()
+ output_queue: asyncio.Queue[bytes] = asyncio.Queue()
+
+ def on_output(data: bytes) -> None:
+ try:
+ loop.call_soon_threadsafe(output_queue.put_nowait, data)
+ except Exception as e:
+ logger.error("on_output error: %s", e)
+
+ async def output_sender() -> None:
+ """Coroutine that sends queued output to WebSocket."""
+ try:
+ while True:
+ data = await output_queue.get()
+ await websocket.send_json(
+ {
+ "type": "output",
+ "data": base64.b64encode(data).decode("ascii"),
+ }
+ )
+ except asyncio.CancelledError:
+ pass
+ except Exception as e:
+ logger.error("output_sender error: %s", e)
+
+ sender_task = None
+
+ try:
+ session = await manager.create_session(
+ session_id=session_id,
+ on_output=on_output,
+ )
+
+ from code_puppy.api.session_context import session_manager
+
+ try:
+ ctx = await session_manager.create_session(f"terminal_{session_id}")
+ current_agent_name = ctx.agent_name
+ except Exception:
+ current_agent_name = "code-puppy"
+ ctx = None
+
+ await websocket.send_json(
+ {
+ "type": "session_started",
+ "session_id": session_id,
+ "current_agent": current_agent_name,
+ }
+ )
+
+ sender_task = asyncio.create_task(output_sender())
+
+ while True:
+ try:
+ msg = await websocket.receive_json()
+
+ if msg.get("type") == "input":
+ data = msg.get("data", "")
+ if isinstance(data, str):
+ data = data.encode("utf-8")
+ await manager.write(session_id, data)
+ elif msg.get("type") == "resize":
+ cols = msg.get("cols", 80)
+ rows = msg.get("rows", 24)
+ await manager.resize(session_id, cols, rows)
+ elif msg.get("type") == "switch_agent":
+ agent_name = msg.get("agent_name")
+ if agent_name:
+ try:
+ await session_manager.switch_agent(
+ f"terminal_{session_id}", agent_name
+ )
+ if ctx:
+ ctx = await session_manager.get_session(
+ f"terminal_{session_id}"
+ )
+ await websocket.send_json(
+ {"type": "agent_changed", "agent_name": agent_name}
+ )
+ except Exception as e:
+ logger.error("Error switching agent: %s", e)
+ await websocket.send_json(
+ {
+ "type": "error",
+ "message": f"Failed to switch to agent {agent_name}: {str(e)}",
+ }
+ )
+ elif msg.get("type") == "get_current_agent":
+ if ctx:
+ current_agent_name = ctx.agent_name
+ else:
+ current_agent_name = "code-puppy"
+ await websocket.send_json(
+ {"type": "current_agent", "agent_name": current_agent_name}
+ )
+ except WebSocketDisconnect:
+ break
+ except Exception as e:
+ logger.error("Terminal WebSocket error: %s", e)
+ break
+ except Exception as e:
+ logger.error("Terminal session error: %s", e)
+ finally:
+ if sender_task:
+ sender_task.cancel()
+ try:
+ await sender_task
+ except asyncio.CancelledError:
+ pass
+ if ctx:
+ await session_manager.destroy_session(f"terminal_{session_id}")
+ if session:
+ await manager.close_session(session_id)
+ logger.debug("Terminal WebSocket disconnected")
From 796062f74525e287e384a12f8ad2b8b7bf6ae093 Mon Sep 17 00:00:00 2001
From: shravya maddipudi
Date: Thu, 28 May 2026 18:38:46 -0500
Subject: [PATCH 06/29] test(api): migrate api/ws test suites and desk
templates
---
code_puppy/api/templates/chat.html | 942 ++++++++++++++++++
code_puppy/api/templates/terminal.html | 382 +++++++
code_puppy/api/tests/__init__.py | 0
.../api/tests/test_backfill_pydantic_json.py | 349 +++++++
code_puppy/api/tests/test_session_load.py | 503 ++++++++++
.../api/tests/test_session_model_compat.py | 64 ++
code_puppy/api/ws/tests/__init__.py | 0
.../api/ws/tests/test_background_save.py | 559 +++++++++++
.../api/ws/tests/test_event_driven_drain.py | 556 +++++++++++
.../api/ws/tests/test_event_driven_polling.py | 337 +++++++
.../tests/test_tool_group_id_consistency.py | 67 ++
11 files changed, 3759 insertions(+)
create mode 100644 code_puppy/api/templates/chat.html
create mode 100644 code_puppy/api/templates/terminal.html
create mode 100644 code_puppy/api/tests/__init__.py
create mode 100644 code_puppy/api/tests/test_backfill_pydantic_json.py
create mode 100644 code_puppy/api/tests/test_session_load.py
create mode 100644 code_puppy/api/tests/test_session_model_compat.py
create mode 100644 code_puppy/api/ws/tests/__init__.py
create mode 100644 code_puppy/api/ws/tests/test_background_save.py
create mode 100644 code_puppy/api/ws/tests/test_event_driven_drain.py
create mode 100644 code_puppy/api/ws/tests/test_event_driven_polling.py
create mode 100644 code_puppy/api/ws/tests/test_tool_group_id_consistency.py
diff --git a/code_puppy/api/templates/chat.html b/code_puppy/api/templates/chat.html
new file mode 100644
index 000000000..b23d44951
--- /dev/null
+++ b/code_puppy/api/templates/chat.html
@@ -0,0 +1,942 @@
+
+
+
+
+
+ 🐶 Code Puppy Chat
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/code_puppy/api/templates/terminal.html b/code_puppy/api/templates/terminal.html
new file mode 100644
index 000000000..568dc7491
--- /dev/null
+++ b/code_puppy/api/templates/terminal.html
@@ -0,0 +1,382 @@
+
+
+
+
+
+ 🐶 Code Puppy Terminal
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/code_puppy/api/tests/__init__.py b/code_puppy/api/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/code_puppy/api/tests/test_backfill_pydantic_json.py b/code_puppy/api/tests/test_backfill_pydantic_json.py
new file mode 100644
index 000000000..db026dc03
--- /dev/null
+++ b/code_puppy/api/tests/test_backfill_pydantic_json.py
@@ -0,0 +1,349 @@
+"""Unit tests for code_puppy.api.db.backfill.backfill_pydantic_json.
+
+All tests spin up a real in-memory aiosqlite database with the minimal schema
+needed — no mocking of the DB layer, no patching of pydantic-ai. The goal
+is to exercise the actual serialisation round-trip so we catch pydantic-ai
+API changes as early as possible.
+
+Test inventory
+--------------
+ 1. Empty table → (0, 0) returned, no crash
+ 2. All rows already filled → (0, 0), nothing overwritten
+ 3. User message backfilled → pydantic_json set, deserialises to ModelRequest
+ 4. Assistant message backf. → pydantic_json set, deserialises to ModelResponse
+ 5. System rows skipped → pydantic_json remains NULL
+ 6. Compacted rows skipped → pydantic_json remains NULL
+ 7. Mixed NULL + non-NULL → only NULL rows updated
+ 8. Empty content string → backfilled with empty UserPromptPart (no crash)
+ 9. Idempotent (run twice) → second run updates 0 rows
+10. Batch splitting → batch_size=2 with 5 rows all updated correctly
+11. Serialised JSON is valid → round-trips through ModelMessagesTypeAdapter
+12. _build_message bad role → raises ValueError
+"""
+
+from __future__ import annotations
+
+import pytest
+
+# ---------------------------------------------------------------------------
+# In-memory DB fixture
+# ---------------------------------------------------------------------------
+
+_CREATE_MESSAGES = """
+CREATE TABLE messages (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ session_id TEXT NOT NULL,
+ seq INTEGER NOT NULL,
+ role TEXT NOT NULL,
+ content TEXT DEFAULT '',
+ model_name TEXT DEFAULT '',
+ compacted INTEGER DEFAULT 0,
+ pydantic_json TEXT
+);
+"""
+
+
+async def _make_db():
+ """Return an open in-memory aiosqlite connection with the messages table."""
+ import aiosqlite
+
+ db = await aiosqlite.connect(":memory:")
+ db.row_factory = aiosqlite.Row
+ await db.execute(_CREATE_MESSAGES)
+ await db.commit()
+ return db
+
+
+async def _insert(
+ db,
+ *,
+ session_id: str = "s1",
+ seq: int = 1,
+ role: str = "user",
+ content: str = "hello",
+ model_name: str = "gpt-4o",
+ compacted: int = 0,
+ pydantic_json: str | None = None,
+) -> int:
+ """Insert one row and return its id."""
+ cursor = await db.execute(
+ """
+ INSERT INTO messages (session_id, seq, role, content, model_name, compacted, pydantic_json)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ """,
+ (session_id, seq, role, content, model_name, compacted, pydantic_json),
+ )
+ await db.commit()
+ return cursor.lastrowid
+
+
+async def _fetch_pj(db, row_id: int) -> str | None:
+ """Fetch pydantic_json for a specific row id."""
+ cursor = await db.execute(
+ "SELECT pydantic_json FROM messages WHERE id = ?", (row_id,)
+ )
+ row = await cursor.fetchone()
+ return row["pydantic_json"] if row else None
+
+
+# ---------------------------------------------------------------------------
+# Tests
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_empty_table_returns_zero_counts():
+ """No rows → (0, 0) with no crash."""
+ from code_puppy.api.db.backfill import backfill_pydantic_json
+
+ db = await _make_db()
+ updated, skipped = await backfill_pydantic_json(db)
+ await db.close()
+
+ assert updated == 0
+ assert skipped == 0
+
+
+@pytest.mark.asyncio
+async def test_all_rows_already_filled_returns_zero_counts():
+ """Rows with existing pydantic_json are not touched."""
+ from code_puppy.api.db.backfill import backfill_pydantic_json
+
+ db = await _make_db()
+ existing_pj = '{"already": "there"}'
+ await _insert(db, pydantic_json=existing_pj)
+
+ updated, skipped = await backfill_pydantic_json(db)
+ await db.close()
+
+ assert updated == 0
+ assert skipped == 0
+
+
+@pytest.mark.asyncio
+async def test_user_message_backfilled_to_model_request():
+ """A user row with NULL pydantic_json is backfilled with a ModelRequest."""
+ from pydantic_ai.messages import ModelMessagesTypeAdapter, ModelRequest
+
+ from code_puppy.api.db.backfill import backfill_pydantic_json
+
+ db = await _make_db()
+ row_id = await _insert(db, role="user", content="hello world", pydantic_json=None)
+
+ updated, skipped = await backfill_pydantic_json(db)
+
+ pj = await _fetch_pj(db, row_id)
+ await db.close()
+
+ assert updated == 1
+ assert skipped == 0
+ assert pj is not None
+
+ # Round-trip check
+ msgs = ModelMessagesTypeAdapter.validate_json(pj)
+ assert len(msgs) == 1
+ assert isinstance(msgs[0], ModelRequest)
+ # Content preserved
+ content_parts = [p.content for p in msgs[0].parts if hasattr(p, "content")]
+ assert "hello world" in content_parts
+
+
+@pytest.mark.asyncio
+async def test_assistant_message_backfilled_to_model_response():
+ """An assistant row with NULL pydantic_json is backfilled with a ModelResponse."""
+ from pydantic_ai.messages import ModelMessagesTypeAdapter, ModelResponse
+
+ from code_puppy.api.db.backfill import backfill_pydantic_json
+
+ db = await _make_db()
+ row_id = await _insert(
+ db,
+ role="assistant",
+ content="here is my answer",
+ model_name="claude-3",
+ pydantic_json=None,
+ )
+
+ updated, skipped = await backfill_pydantic_json(db)
+
+ pj = await _fetch_pj(db, row_id)
+ await db.close()
+
+ assert updated == 1
+ assert skipped == 0
+ assert pj is not None
+
+ msgs = ModelMessagesTypeAdapter.validate_json(pj)
+ assert len(msgs) == 1
+ assert isinstance(msgs[0], ModelResponse)
+ content_parts = [p.content for p in msgs[0].parts if hasattr(p, "content")]
+ assert "here is my answer" in content_parts
+
+
+@pytest.mark.asyncio
+async def test_system_rows_are_skipped():
+ """Rows with role='system' are excluded from the backfill query."""
+ from code_puppy.api.db.backfill import backfill_pydantic_json
+
+ db = await _make_db()
+ row_id = await _insert(
+ db, role="system", content="you are helpful", pydantic_json=None
+ )
+
+ updated, skipped = await backfill_pydantic_json(db)
+ pj = await _fetch_pj(db, row_id)
+ await db.close()
+
+ assert updated == 0
+ assert skipped == 0
+ assert pj is None, "system rows must stay NULL"
+
+
+@pytest.mark.asyncio
+async def test_compacted_rows_are_skipped():
+ """Rows with compacted=1 are excluded from the backfill query."""
+ from code_puppy.api.db.backfill import backfill_pydantic_json
+
+ db = await _make_db()
+ row_id = await _insert(
+ db, role="user", content="old message", compacted=1, pydantic_json=None
+ )
+
+ updated, skipped = await backfill_pydantic_json(db)
+ pj = await _fetch_pj(db, row_id)
+ await db.close()
+
+ assert updated == 0
+ assert skipped == 0
+ assert pj is None, "compacted rows must stay NULL"
+
+
+@pytest.mark.asyncio
+async def test_mixed_null_and_filled_rows():
+ """Only NULL rows are updated; already-filled rows are untouched."""
+ from code_puppy.api.db.backfill import backfill_pydantic_json
+
+ db = await _make_db()
+ existing_pj = '{"sentinel": true}'
+
+ id_null = await _insert(db, seq=1, pydantic_json=None)
+ id_filled = await _insert(db, seq=2, pydantic_json=existing_pj)
+
+ updated, skipped = await backfill_pydantic_json(db)
+
+ pj_null = await _fetch_pj(db, id_null)
+ pj_filled = await _fetch_pj(db, id_filled)
+ await db.close()
+
+ assert updated == 1
+ assert pj_null is not None, "NULL row should have been filled"
+ assert pj_filled == existing_pj, "Already-filled row must not be overwritten"
+
+
+@pytest.mark.asyncio
+async def test_empty_content_backfilled_without_crash():
+ """Empty string content produces a valid pydantic_json (no crash)."""
+ from pydantic_ai.messages import ModelMessagesTypeAdapter
+
+ from code_puppy.api.db.backfill import backfill_pydantic_json
+
+ db = await _make_db()
+ row_id = await _insert(db, role="user", content="", pydantic_json=None)
+
+ updated, skipped = await backfill_pydantic_json(db)
+ pj = await _fetch_pj(db, row_id)
+ await db.close()
+
+ assert updated == 1
+ assert skipped == 0
+ assert pj is not None
+ # Must be parseable
+ msgs = ModelMessagesTypeAdapter.validate_json(pj)
+ assert len(msgs) == 1
+
+
+@pytest.mark.asyncio
+async def test_idempotent_second_run_updates_nothing():
+ """Running the backfill twice produces updated=0 on the second run."""
+ from code_puppy.api.db.backfill import backfill_pydantic_json
+
+ db = await _make_db()
+ await _insert(db, role="user", content="test", pydantic_json=None)
+
+ updated1, _ = await backfill_pydantic_json(db)
+ updated2, _ = await backfill_pydantic_json(db)
+ await db.close()
+
+ assert updated1 == 1
+ assert updated2 == 0, "Second run must be a no-op"
+
+
+@pytest.mark.asyncio
+async def test_batch_splitting_all_rows_updated():
+ """batch_size=2 with 5 rows — all rows must be updated correctly."""
+ from pydantic_ai.messages import ModelMessagesTypeAdapter
+
+ from code_puppy.api.db.backfill import backfill_pydantic_json
+
+ db = await _make_db()
+ row_ids = []
+ for i in range(5):
+ rid = await _insert(
+ db,
+ seq=i + 1,
+ role="user",
+ content=f"message {i}",
+ pydantic_json=None,
+ )
+ row_ids.append(rid)
+
+ updated, skipped = await backfill_pydantic_json(db, batch_size=2)
+
+ for rid in row_ids:
+ pj = await _fetch_pj(db, rid)
+ assert pj is not None, f"Row {rid} should have been backfilled"
+ msgs = ModelMessagesTypeAdapter.validate_json(pj)
+ assert len(msgs) == 1
+
+ await db.close()
+ assert updated == 5
+ assert skipped == 0
+
+
+@pytest.mark.asyncio
+async def test_serialised_json_round_trips_correctly():
+ """The generated pydantic_json must survive a full serialise → deserialise cycle."""
+ from pydantic_ai.messages import (
+ ModelMessagesTypeAdapter,
+ ModelRequest,
+ UserPromptPart,
+ )
+
+ from code_puppy.api.db.backfill import backfill_pydantic_json
+
+ original_content = "does this survive the round trip? 🐶"
+ db = await _make_db()
+ row_id = await _insert(
+ db, role="user", content=original_content, pydantic_json=None
+ )
+
+ await backfill_pydantic_json(db)
+ pj = await _fetch_pj(db, row_id)
+ await db.close()
+
+ msgs = ModelMessagesTypeAdapter.validate_json(pj)
+ assert isinstance(msgs[0], ModelRequest)
+ prompt_parts = [p for p in msgs[0].parts if isinstance(p, UserPromptPart)]
+ assert len(prompt_parts) == 1
+ assert prompt_parts[0].content == original_content
+
+
+def test_build_message_bad_role_raises():
+ """_build_message raises ValueError for unsupported roles."""
+ from code_puppy.api.db.backfill import _build_message
+
+ with pytest.raises(ValueError, match="Unsupported role"):
+ _build_message("system", "content", "model")
+
+ with pytest.raises(ValueError, match="Unsupported role"):
+ _build_message("tool", "content", "model")
diff --git a/code_puppy/api/tests/test_session_load.py b/code_puppy/api/tests/test_session_load.py
new file mode 100644
index 000000000..674f2eae4
--- /dev/null
+++ b/code_puppy/api/tests/test_session_load.py
@@ -0,0 +1,503 @@
+"""Unit tests for SessionManager._load_from_sqlite and .load_session.
+
+Phase 4 coverage from the session-management bug plan:
+
+_load_from_sqlite:
+ 1. Session not in DB → returns None
+ 2. SQLite import unavailable → returns None
+ 3. get_active_messages raises → returns None (outer except)
+ 4. All rows NULL pydantic_json → returns ([], meta) – graceful degradation
+ 5. Mixed NULL + valid rows → only valid rows parsed
+ 6. Valid rows → messages parsed correctly
+ 7. Malformed pydantic_json → row skipped + warning, rest succeed
+ 8. Metadata populated from sessions table
+ 9. No session row in sessions table → meta = {}
+10. Sessions table query fails → meta = {}
+
+load_session:
+11. Invalid session_id → ValueError before touching DB
+12. _load_from_sqlite → None → load_session returns None
+13. Normal happy path → history set, ctx in _sessions
+14. Unknown agent_name in DB → falls back to code-puppy
+15. Valid created_at string → parsed as datetime correctly
+16. Malformed created_at → datetime.now() fallback
+17. Empty created_at → datetime.now() fallback
+18. Session registered in _sessions after load
+"""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _make_pydantic_json(content: str = "hello from user") -> str:
+ """Produce a valid pydantic_json string using real pydantic-ai types."""
+ from pydantic_ai.messages import (
+ ModelMessagesTypeAdapter,
+ ModelRequest,
+ UserPromptPart,
+ )
+
+ msg = ModelRequest(parts=[UserPromptPart(content=content)])
+ return ModelMessagesTypeAdapter.dump_json([msg]).decode("utf-8")
+
+
+def _make_response_pydantic_json(content: str = "hello from assistant") -> str:
+ """Produce a valid pydantic_json string for a ModelResponse."""
+ from pydantic_ai.messages import ModelMessagesTypeAdapter, ModelResponse, TextPart
+
+ msg = ModelResponse(parts=[TextPart(content=content)], model_name="test-model")
+ return ModelMessagesTypeAdapter.dump_json([msg]).decode("utf-8")
+
+
+def _make_row(
+ seq: int = 1,
+ role: str = "user",
+ pydantic_json: str | None = None,
+) -> dict:
+ """Build a minimal message row dict as returned by get_active_messages."""
+ return {
+ "seq": seq,
+ "role": role,
+ "content": "some content",
+ "pydantic_json": pydantic_json,
+ }
+
+
+def _make_session_row(
+ session_id: str = "test-session",
+ agent_name: str = "code-puppy",
+ model_name: str = "gpt-4o",
+ title: str = "Test Session",
+ pinned: bool = False,
+ working_directory: str = "/tmp",
+ created_at: str = "2026-01-01T00:00:00+00:00",
+) -> dict:
+ """Build a minimal sessions table row dict."""
+ return {
+ "session_id": session_id,
+ "agent_name": agent_name,
+ "model_name": model_name,
+ "title": title,
+ "pinned": 1 if pinned else 0,
+ "working_directory": working_directory,
+ "created_at": created_at,
+ }
+
+
+def _make_db_mock(session_row: dict | None) -> MagicMock:
+ """Return a db mock whose cursor.fetchone() returns *session_row*."""
+ cursor = AsyncMock()
+ cursor.fetchone.return_value = session_row
+ db = MagicMock()
+ db.execute = AsyncMock(return_value=cursor)
+ return db
+
+
+def _make_fresh_manager():
+ """Return a brand-new SessionManager instance (not the module singleton)."""
+ from code_puppy.api.session_context import SessionManager
+
+ return SessionManager()
+
+
+def _make_agent_mock(history: list | None = None) -> MagicMock:
+ """Return a minimal agent mock."""
+ agent = MagicMock()
+ agent.get_message_history.return_value = history or []
+ agent.set_message_history.return_value = None
+ agent.set_session_model.return_value = None
+ return agent
+
+
+# ---------------------------------------------------------------------------
+# _load_from_sqlite tests
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_load_from_sqlite_session_not_in_db():
+ """session_exists returns False → _load_from_sqlite returns None."""
+ mgr = _make_fresh_manager()
+ db_mock = _make_db_mock(None)
+
+ with (
+ patch("code_puppy.api.db.queries.session_exists", new=AsyncMock(return_value=False)),
+ patch("code_puppy.api.db.queries.get_active_messages", new=AsyncMock(return_value=[])),
+ patch("code_puppy.api.db.connection.get_db", return_value=db_mock),
+ ):
+ result = await mgr._load_from_sqlite("test-session")
+
+ assert result is None
+
+
+@pytest.mark.asyncio
+async def test_load_from_sqlite_import_error_returns_none():
+ """If the DB queries module is unavailable, _load_from_sqlite returns None."""
+ mgr = _make_fresh_manager()
+
+ # Raising ImportError inside the try block that wraps the lazy import
+ # is the canonical way to simulate "SQLite not available".
+ with patch(
+ "code_puppy.api.db.queries.session_exists",
+ side_effect=ImportError("aiosqlite not installed"),
+ ):
+ result = await mgr._load_from_sqlite("test-session")
+
+ assert result is None
+
+
+@pytest.mark.asyncio
+async def test_load_from_sqlite_get_active_messages_raises_returns_none():
+ """An exception from get_active_messages is caught → None."""
+ mgr = _make_fresh_manager()
+
+ with (
+ patch("code_puppy.api.db.queries.session_exists", new=AsyncMock(return_value=True)),
+ patch(
+ "code_puppy.api.db.queries.get_active_messages",
+ new=AsyncMock(side_effect=RuntimeError("db locked")),
+ ),
+ ):
+ result = await mgr._load_from_sqlite("test-session")
+
+ assert result is None
+
+
+@pytest.mark.asyncio
+async def test_load_from_sqlite_all_null_pydantic_json_returns_empty_history():
+ """All rows have pydantic_json=None → empty message list, but NOT None.
+
+ This is graceful degradation: the session exists but has no serialisable
+ messages (e.g. seeded without the BE write path). The agent starts with
+ empty history rather than crashing.
+ """
+ mgr = _make_fresh_manager()
+ rows = [
+ _make_row(seq=1, role="user", pydantic_json=None),
+ _make_row(seq=2, role="assistant", pydantic_json=None),
+ ]
+ db_mock = _make_db_mock(_make_session_row())
+
+ with (
+ patch("code_puppy.api.db.queries.session_exists", new=AsyncMock(return_value=True)),
+ patch("code_puppy.api.db.queries.get_active_messages", new=AsyncMock(return_value=rows)),
+ patch("code_puppy.api.db.connection.get_db", return_value=db_mock),
+ ):
+ result = await mgr._load_from_sqlite("test-session")
+
+ assert result is not None, "Should return (messages, meta) not None"
+ messages, meta = result
+ assert messages == [], "Empty history expected when all rows have NULL pydantic_json"
+
+
+@pytest.mark.asyncio
+async def test_load_from_sqlite_mixed_null_and_valid_rows():
+ """NULL pydantic_json rows are skipped; valid rows are parsed."""
+ mgr = _make_fresh_manager()
+ valid_json = _make_pydantic_json("hello")
+ rows = [
+ _make_row(seq=1, role="user", pydantic_json=None), # NULL – skip
+ _make_row(seq=2, role="user", pydantic_json=valid_json), # valid
+ _make_row(seq=3, role="user", pydantic_json=None), # NULL – skip
+ ]
+ db_mock = _make_db_mock(_make_session_row())
+
+ with (
+ patch("code_puppy.api.db.queries.session_exists", new=AsyncMock(return_value=True)),
+ patch("code_puppy.api.db.queries.get_active_messages", new=AsyncMock(return_value=rows)),
+ patch("code_puppy.api.db.connection.get_db", return_value=db_mock),
+ ):
+ result = await mgr._load_from_sqlite("test-session")
+
+ assert result is not None
+ messages, _ = result
+ assert len(messages) == 1, "Only the valid row should be parsed"
+
+
+@pytest.mark.asyncio
+async def test_load_from_sqlite_valid_rows_parsed_correctly():
+ """Valid pydantic_json rows deserialise into proper ModelMessage objects."""
+ from pydantic_ai.messages import ModelRequest
+
+ mgr = _make_fresh_manager()
+ user_json = _make_pydantic_json("user turn")
+ assistant_json = _make_response_pydantic_json("assistant reply")
+ rows = [
+ _make_row(seq=1, role="user", pydantic_json=user_json),
+ _make_row(seq=2, role="assistant", pydantic_json=assistant_json),
+ ]
+ db_mock = _make_db_mock(_make_session_row())
+
+ with (
+ patch("code_puppy.api.db.queries.session_exists", new=AsyncMock(return_value=True)),
+ patch("code_puppy.api.db.queries.get_active_messages", new=AsyncMock(return_value=rows)),
+ patch("code_puppy.api.db.connection.get_db", return_value=db_mock),
+ ):
+ result = await mgr._load_from_sqlite("test-session")
+
+ assert result is not None
+ messages, _ = result
+ assert len(messages) == 2
+ # First message must be a ModelRequest (user turn)
+ assert isinstance(messages[0], ModelRequest)
+
+
+@pytest.mark.asyncio
+async def test_load_from_sqlite_malformed_pydantic_json_skipped(caplog):
+ """A row with corrupt JSON is skipped with a warning; valid rows still load."""
+ import logging
+
+ mgr = _make_fresh_manager()
+ valid_json = _make_pydantic_json("good message")
+ rows = [
+ _make_row(seq=1, role="user", pydantic_json="{this is not valid json!!!"),
+ _make_row(seq=2, role="user", pydantic_json=valid_json),
+ ]
+ db_mock = _make_db_mock(_make_session_row())
+
+ with (
+ patch("code_puppy.api.db.queries.session_exists", new=AsyncMock(return_value=True)),
+ patch("code_puppy.api.db.queries.get_active_messages", new=AsyncMock(return_value=rows)),
+ patch("code_puppy.api.db.connection.get_db", return_value=db_mock),
+ caplog.at_level(logging.WARNING, logger="code_puppy.api.session_context"),
+ ):
+ result = await mgr._load_from_sqlite("test-session")
+
+ assert result is not None
+ messages, _ = result
+ # Only the valid row should come through
+ assert len(messages) == 1
+ # A warning must have been emitted for the bad row
+ assert any("Failed to deserialise" in r.message for r in caplog.records)
+
+
+@pytest.mark.asyncio
+async def test_load_from_sqlite_metadata_populated():
+ """Meta dict is populated from the sessions table row."""
+ mgr = _make_fresh_manager()
+ session_row = _make_session_row(
+ agent_name="planning-agent",
+ model_name="claude-3",
+ title="My Session",
+ pinned=True,
+ working_directory="/home/user/project",
+ )
+ db_mock = _make_db_mock(session_row)
+
+ with (
+ patch("code_puppy.api.db.queries.session_exists", new=AsyncMock(return_value=True)),
+ patch("code_puppy.api.db.queries.get_active_messages", new=AsyncMock(return_value=[])),
+ patch("code_puppy.api.db.connection.get_db", return_value=db_mock),
+ ):
+ result = await mgr._load_from_sqlite("test-session")
+
+ assert result is not None
+ _, meta = result
+ assert meta["agent_name"] == "planning-agent"
+ assert meta["model_name"] == "claude-3"
+ assert meta["title"] == "My Session"
+ assert meta["working_directory"] == "/home/user/project"
+
+
+@pytest.mark.asyncio
+async def test_load_from_sqlite_no_session_row_gives_empty_meta():
+ """If the sessions table has no row, meta is an empty dict."""
+ mgr = _make_fresh_manager()
+ db_mock = _make_db_mock(None) # cursor.fetchone() → None
+
+ with (
+ patch("code_puppy.api.db.queries.session_exists", new=AsyncMock(return_value=True)),
+ patch("code_puppy.api.db.queries.get_active_messages", new=AsyncMock(return_value=[])),
+ patch("code_puppy.api.db.connection.get_db", return_value=db_mock),
+ ):
+ result = await mgr._load_from_sqlite("test-session")
+
+ assert result is not None
+ _, meta = result
+ assert meta == {}
+
+
+@pytest.mark.asyncio
+async def test_load_from_sqlite_sessions_table_query_fails_gives_empty_meta():
+ """If the sessions table query raises, meta falls back to {} without crashing."""
+ mgr = _make_fresh_manager()
+
+ # get_db() itself raises on first call (for the metadata query only)
+ failing_db = MagicMock()
+ failing_db.execute = AsyncMock(side_effect=RuntimeError("table locked"))
+
+ with (
+ patch("code_puppy.api.db.queries.session_exists", new=AsyncMock(return_value=True)),
+ patch("code_puppy.api.db.queries.get_active_messages", new=AsyncMock(return_value=[])),
+ patch("code_puppy.api.db.connection.get_db", return_value=failing_db),
+ ):
+ result = await mgr._load_from_sqlite("test-session")
+
+ assert result is not None
+ _, meta = result
+ assert meta == {}
+
+
+# ---------------------------------------------------------------------------
+# load_session tests
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_load_session_invalid_session_id_raises():
+ """load_session raises ValueError before touching the DB for bad IDs."""
+ mgr = _make_fresh_manager()
+ with pytest.raises(ValueError, match="Invalid session_id"):
+ await mgr.load_session("../etc/passwd")
+
+
+@pytest.mark.asyncio
+async def test_load_session_returns_none_when_sqlite_has_no_data():
+ """If _load_from_sqlite returns None, load_session returns None."""
+ mgr = _make_fresh_manager()
+ mgr._load_from_sqlite = AsyncMock(return_value=None)
+
+ result = await mgr.load_session("valid-session-id")
+
+ assert result is None
+
+
+@pytest.mark.asyncio
+async def test_load_session_happy_path_sets_history_and_registers_context():
+ """Normal path: history loaded from SQLite is set on the agent, and the
+ context is registered in the manager's _sessions dict.
+ """
+ from pydantic_ai.messages import ModelRequest, UserPromptPart
+
+ mgr = _make_fresh_manager()
+ real_msg = ModelRequest(parts=[UserPromptPart(content="hello")])
+ db_meta = _make_session_row(
+ agent_name="code-puppy",
+ model_name="gpt-4o",
+ title="My Chat",
+ created_at="2026-01-15T12:00:00+00:00",
+ )
+ mgr._load_from_sqlite = AsyncMock(return_value=([real_msg], db_meta))
+ agent_mock = _make_agent_mock()
+
+ with (
+ patch("code_puppy.api.session_context.load_agent", return_value=agent_mock),
+ patch("code_puppy.api.session_context.get_global_model_name", return_value="gpt-4o"),
+ ):
+ ctx = await mgr.load_session("my-chat-session")
+
+ assert ctx is not None
+ assert ctx.session_id == "my-chat-session"
+ assert ctx.agent is agent_mock
+ assert ctx.agent_name == "code-puppy"
+ assert ctx.model_name == "gpt-4o"
+ assert ctx.title == "My Chat"
+ # set_message_history must have been called with the loaded messages
+ agent_mock.set_message_history.assert_called_once_with([real_msg])
+
+
+@pytest.mark.asyncio
+async def test_load_session_registers_context_in_sessions_dict():
+ """The loaded SessionContext must end up in SessionManager._sessions."""
+ mgr = _make_fresh_manager()
+ db_meta = _make_session_row()
+ mgr._load_from_sqlite = AsyncMock(return_value=([], db_meta))
+ agent_mock = _make_agent_mock()
+
+ with (
+ patch("code_puppy.api.session_context.load_agent", return_value=agent_mock),
+ patch("code_puppy.api.session_context.get_global_model_name", return_value="gpt-4o"),
+ ):
+ ctx = await mgr.load_session("registered-session")
+
+ assert "registered-session" in mgr._sessions
+ assert mgr._sessions["registered-session"] is ctx
+
+
+@pytest.mark.asyncio
+async def test_load_session_unknown_agent_falls_back_to_code_puppy():
+ """If the agent stored in DB is no longer available, code-puppy is used."""
+ mgr = _make_fresh_manager()
+ db_meta = _make_session_row(agent_name="deleted-custom-agent")
+ mgr._load_from_sqlite = AsyncMock(return_value=([], db_meta))
+ fallback_agent = _make_agent_mock()
+
+ def _load_agent_side_effect(name):
+ if name == "deleted-custom-agent":
+ raise ValueError(f"Unknown agent {name!r}")
+ return fallback_agent
+
+ with (
+ patch("code_puppy.api.session_context.load_agent", side_effect=_load_agent_side_effect),
+ patch("code_puppy.api.session_context.get_global_model_name", return_value="gpt-4o"),
+ ):
+ ctx = await mgr.load_session("fallback-session")
+
+ assert ctx is not None
+ assert ctx.agent_name == "code-puppy"
+ assert ctx.agent is fallback_agent
+
+
+@pytest.mark.asyncio
+async def test_load_session_valid_created_at_parsed():
+ """A valid ISO created_at string in DB meta is parsed into a datetime."""
+ mgr = _make_fresh_manager()
+ iso = "2026-03-15T09:30:00+00:00"
+ db_meta = _make_session_row(created_at=iso)
+ mgr._load_from_sqlite = AsyncMock(return_value=([], db_meta))
+
+ with (
+ patch("code_puppy.api.session_context.load_agent", return_value=_make_agent_mock()),
+ patch("code_puppy.api.session_context.get_global_model_name", return_value="gpt-4o"),
+ ):
+ ctx = await mgr.load_session("dated-session")
+
+ assert ctx is not None
+ expected = datetime.fromisoformat(iso)
+ assert ctx.created_at == expected
+
+
+@pytest.mark.asyncio
+async def test_load_session_malformed_created_at_falls_back_to_now():
+ """A malformed created_at string results in datetime.now() being used."""
+ mgr = _make_fresh_manager()
+ db_meta = _make_session_row(created_at="not-a-date")
+ mgr._load_from_sqlite = AsyncMock(return_value=([], db_meta))
+ before = datetime.now(timezone.utc)
+
+ with (
+ patch("code_puppy.api.session_context.load_agent", return_value=_make_agent_mock()),
+ patch("code_puppy.api.session_context.get_global_model_name", return_value="gpt-4o"),
+ ):
+ ctx = await mgr.load_session("bad-date-session")
+
+ after = datetime.now(timezone.utc)
+ assert ctx is not None
+ assert before <= ctx.created_at <= after, "created_at should be close to now()"
+
+
+@pytest.mark.asyncio
+async def test_load_session_empty_created_at_falls_back_to_now():
+ """An empty created_at string also results in datetime.now() being used."""
+ mgr = _make_fresh_manager()
+ db_meta = _make_session_row(created_at="")
+ mgr._load_from_sqlite = AsyncMock(return_value=([], db_meta))
+ before = datetime.now(timezone.utc)
+
+ with (
+ patch("code_puppy.api.session_context.load_agent", return_value=_make_agent_mock()),
+ patch("code_puppy.api.session_context.get_global_model_name", return_value="gpt-4o"),
+ ):
+ ctx = await mgr.load_session("empty-date-session")
+
+ after = datetime.now(timezone.utc)
+ assert ctx is not None
+ assert before <= ctx.created_at <= after
\ No newline at end of file
diff --git a/code_puppy/api/tests/test_session_model_compat.py b/code_puppy/api/tests/test_session_model_compat.py
new file mode 100644
index 000000000..0b008fd87
--- /dev/null
+++ b/code_puppy/api/tests/test_session_model_compat.py
@@ -0,0 +1,64 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+
+import pytest
+
+from code_puppy.api.session_context import SessionContext, SessionManager, _apply_session_model
+
+
+class LegacyAgentWithoutSetter:
+ def __init__(self) -> None:
+ self._session_model_name = None
+ self.reload_count = 0
+
+ def reload_code_generation_agent(self) -> None:
+ self.reload_count += 1
+
+
+class ModernAgentWithSetter(LegacyAgentWithoutSetter):
+ def __init__(self) -> None:
+ super().__init__()
+ self.via_method = None
+
+ def set_session_model(self, model_name):
+ self.via_method = model_name
+
+
+@pytest.mark.parametrize(
+ "agent_cls,expected_attr,expected_via_method",
+ [
+ (LegacyAgentWithoutSetter, "gpt-5.1", None),
+ (ModernAgentWithSetter, None, "gpt-5.1"),
+ ],
+)
+def test_apply_session_model_compat(agent_cls, expected_attr, expected_via_method):
+ agent = agent_cls()
+ _apply_session_model(agent, "gpt-5.1")
+
+ if expected_attr is not None:
+ assert getattr(agent, "_session_model_name") == expected_attr
+ if expected_via_method is not None:
+ assert getattr(agent, "via_method") == expected_via_method
+
+
+@pytest.mark.asyncio
+async def test_switch_model_supports_legacy_agent_without_setter():
+ mgr = SessionManager()
+ agent = LegacyAgentWithoutSetter()
+ ctx = SessionContext(
+ session_id="legacy-session",
+ agent=agent,
+ agent_name="code-puppy",
+ model_name="synthetic-GLM-5.1",
+ working_directory="",
+ created_at=datetime.now(timezone.utc),
+ )
+
+ mgr._sessions[ctx.session_id] = ctx
+
+ await mgr.switch_model(ctx.session_id, "gpt-5.1")
+
+ assert ctx.model_name == "gpt-5.1"
+ assert agent._session_model_name == "gpt-5.1"
+ assert agent.reload_count == 1
diff --git a/code_puppy/api/ws/tests/__init__.py b/code_puppy/api/ws/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/code_puppy/api/ws/tests/test_background_save.py b/code_puppy/api/ws/tests/test_background_save.py
new file mode 100644
index 000000000..15919df9d
--- /dev/null
+++ b/code_puppy/api/ws/tests/test_background_save.py
@@ -0,0 +1,559 @@
+"""Unit tests for code_puppy.api.ws.background_save.
+
+Covers every edge case identified in the Phase 3 plan:
+
+1. fire_and_track GC protection (task registered + deregistered).
+2. None agent_task → early return, write never called.
+3. Cancelled agent task → early return, write never called.
+4. Failed agent task → early return, write never called.
+5. None result from task → early return, write never called.
+6. Empty history → early return, write never called.
+7. Session deleted during run → write skipped (resurrection guard).
+8. session_exists check failure → write proceeds (fail-safe).
+9. ctx without created_at attribute → now_iso fallback used.
+10. ctx is None → now_iso fallback used.
+11. Pre-wrapped dict history entries pass through unchanged.
+12. Bare ModelMessage-like objects get wrapped with agent/model/ts.
+13. Token computation is called per message (not 0 as before).
+14. write_turn_to_sqlite receives correct keyword arguments.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import datetime
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _make_agent(history=None, token_per_msg=10):
+ """Return a minimal agent mock."""
+ agent = MagicMock()
+ agent.get_message_history.return_value = history or []
+ agent.set_message_history.return_value = None
+ agent.estimate_tokens_for_message.return_value = token_per_msg
+ return agent
+
+
+def _make_result(messages=None):
+ """Return a minimal agent-run result mock."""
+ result = MagicMock()
+ result.all_messages.return_value = messages or []
+ return result
+
+
+def _bare_msg(content="hello"):
+ """Simulate a bare ModelMessage-like object (has .parts attribute)."""
+ msg = MagicMock()
+ msg.__class__.__name__ = "ModelRequest"
+ # Make isinstance(msg, dict) == False (default for MagicMock)
+ return msg
+
+
+def _wrapped_msg(content="wrapped"):
+ """Simulate an already-wrapped history dict entry."""
+ return {"msg": MagicMock(), "agent": "code-puppy", "model": "gpt-4", "ts": "ts"}
+
+
+# ---------------------------------------------------------------------------
+# fire_and_track
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_fire_and_track_registers_and_deregisters_task():
+ """Task should be in _BACKGROUND_TASKS while running and removed after."""
+ from code_puppy.api.ws.background_save import _BACKGROUND_TASKS, fire_and_track
+
+ async def _noop():
+ await asyncio.sleep(0)
+
+ task = fire_and_track(_noop())
+ assert task in _BACKGROUND_TASKS
+
+ await task
+ # done_callback fires synchronously when the task finishes
+ assert task not in _BACKGROUND_TASKS
+
+
+# ---------------------------------------------------------------------------
+# Early-exit guards
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_none_task_returns_immediately():
+ """agent_task=None should be a safe no-op."""
+ from code_puppy.api.ws.background_save import save_agent_result_in_background
+
+ with patch("code_puppy.api.ws.background_save.write_turn_to_sqlite") as mock_write:
+ # patch is not needed since we never reach the write, but kept for safety
+ await save_agent_result_in_background(
+ agent_task=None,
+ session_id="sess-1",
+ ctx=None,
+ agent=_make_agent(),
+ agent_name="puppy",
+ model_name="gpt-4",
+ title="Test",
+ working_directory="/tmp",
+ pinned=False,
+ )
+ mock_write.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_cancelled_task_returns_early():
+ """CancelledError from the agent task should be swallowed — no write."""
+ from code_puppy.api.ws.background_save import save_agent_result_in_background
+
+ async def _cancelled():
+ raise asyncio.CancelledError()
+
+ task = asyncio.ensure_future(_cancelled())
+ with pytest.raises(asyncio.CancelledError):
+ await task # drain so it's actually cancelled
+
+ # Create a new cancelled-style task via a Future
+ cancelled_task = asyncio.get_event_loop().create_future()
+ cancelled_task.cancel()
+
+ with patch("code_puppy.api.ws.background_save.write_turn_to_sqlite") as mock_write:
+ await save_agent_result_in_background(
+ agent_task=cancelled_task,
+ session_id="sess-cancel",
+ ctx=None,
+ agent=_make_agent(),
+ agent_name="puppy",
+ model_name="gpt-4",
+ title="T",
+ working_directory="/",
+ pinned=False,
+ )
+ mock_write.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_failed_task_returns_early():
+ """An exception from the agent task should be swallowed — no write."""
+ from code_puppy.api.ws.background_save import save_agent_result_in_background
+
+ async def _fail():
+ raise RuntimeError("boom")
+
+ task = asyncio.ensure_future(_fail())
+ try:
+ await task
+ except RuntimeError:
+ pass
+
+ # Create a fresh failed future
+ failed_task = asyncio.get_event_loop().create_future()
+ failed_task.set_exception(RuntimeError("agent boom"))
+
+ with patch("code_puppy.api.ws.background_save.write_turn_to_sqlite") as mock_write:
+ await save_agent_result_in_background(
+ agent_task=failed_task,
+ session_id="sess-fail",
+ ctx=None,
+ agent=_make_agent(),
+ agent_name="puppy",
+ model_name="gpt-4",
+ title="T",
+ working_directory="/",
+ pinned=False,
+ )
+ mock_write.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_none_result_returns_early():
+ """result=None from await agent_task should skip the write."""
+ from code_puppy.api.ws.background_save import save_agent_result_in_background
+
+ task_future = asyncio.get_event_loop().create_future()
+ task_future.set_result(None) # agent returned None
+
+ with patch("code_puppy.api.ws.background_save.write_turn_to_sqlite") as mock_write:
+ await save_agent_result_in_background(
+ agent_task=task_future,
+ session_id="sess-none-result",
+ ctx=None,
+ agent=_make_agent(),
+ agent_name="puppy",
+ model_name="gpt-4",
+ title="T",
+ working_directory="/",
+ pinned=False,
+ )
+ mock_write.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_empty_history_returns_early():
+ """Empty message history should skip the write."""
+ from code_puppy.api.ws.background_save import save_agent_result_in_background
+
+ task_future = asyncio.get_event_loop().create_future()
+ task_future.set_result(_make_result(messages=[]))
+
+ agent = _make_agent(history=[]) # empty history after sync
+
+ with (
+ patch(
+ "code_puppy.api.ws.background_save.session_exists", new_callable=AsyncMock
+ ) as mock_exists,
+ patch("code_puppy.api.ws.background_save.write_turn_to_sqlite") as mock_write,
+ ):
+ mock_exists.return_value = True
+ await save_agent_result_in_background(
+ agent_task=task_future,
+ session_id="sess-empty",
+ ctx=None,
+ agent=agent,
+ agent_name="puppy",
+ model_name="gpt-4",
+ title="T",
+ working_directory="/",
+ pinned=False,
+ )
+ mock_write.assert_not_called()
+
+
+# ---------------------------------------------------------------------------
+# Session-deleted resurrection guard
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_deleted_session_skips_write():
+ """If session_exists returns False, write_turn_to_sqlite must NOT be called."""
+ from code_puppy.api.ws.background_save import save_agent_result_in_background
+
+ msg = _bare_msg()
+ task_future = asyncio.get_event_loop().create_future()
+ task_future.set_result(_make_result(messages=[msg]))
+ agent = _make_agent(history=[msg])
+
+ with (
+ patch(
+ "code_puppy.api.ws.background_save.session_exists", new_callable=AsyncMock
+ ) as mock_exists,
+ patch("code_puppy.api.ws.background_save.write_turn_to_sqlite") as mock_write,
+ ):
+ mock_exists.return_value = False # session was deleted
+ await save_agent_result_in_background(
+ agent_task=task_future,
+ session_id="sess-deleted",
+ ctx=None,
+ agent=agent,
+ agent_name="puppy",
+ model_name="gpt-4",
+ title="T",
+ working_directory="/",
+ pinned=False,
+ )
+ mock_exists.assert_awaited_once_with("sess-deleted")
+ mock_write.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_session_exists_failure_proceeds_anyway():
+ """If session_exists raises, we log a warning but still write (fail-safe)."""
+ from code_puppy.api.ws.background_save import save_agent_result_in_background
+
+ msg = _bare_msg()
+ task_future = asyncio.get_event_loop().create_future()
+ task_future.set_result(_make_result(messages=[msg]))
+ agent = _make_agent(history=[msg])
+
+ with (
+ patch(
+ "code_puppy.api.ws.background_save.session_exists",
+ new_callable=AsyncMock,
+ side_effect=Exception("db gone"),
+ ),
+ patch(
+ "code_puppy.api.ws.background_save.write_turn_to_sqlite",
+ new_callable=AsyncMock,
+ ) as mock_write,
+ ):
+ await save_agent_result_in_background(
+ agent_task=task_future,
+ session_id="sess-db-gone",
+ ctx=None,
+ agent=agent,
+ agent_name="puppy",
+ model_name="gpt-4",
+ title="T",
+ working_directory="/",
+ pinned=False,
+ )
+ mock_write.assert_awaited_once()
+
+
+# ---------------------------------------------------------------------------
+# created_at edge cases
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_ctx_without_created_at_uses_now_iso():
+ """ctx that lacks created_at should fall back to now_iso without crashing."""
+ from code_puppy.api.ws.background_save import save_agent_result_in_background
+
+ ctx_no_attr = SimpleNamespace() # no created_at attribute
+ msg = _bare_msg()
+ task_future = asyncio.get_event_loop().create_future()
+ task_future.set_result(_make_result(messages=[msg]))
+ agent = _make_agent(history=[msg])
+
+ with (
+ patch(
+ "code_puppy.api.ws.background_save.session_exists", new_callable=AsyncMock
+ ) as mock_exists,
+ patch(
+ "code_puppy.api.ws.background_save.write_turn_to_sqlite",
+ new_callable=AsyncMock,
+ ) as mock_write,
+ ):
+ mock_exists.return_value = True
+ await save_agent_result_in_background(
+ agent_task=task_future,
+ session_id="sess-no-attr",
+ ctx=ctx_no_attr,
+ agent=agent,
+ agent_name="puppy",
+ model_name="gpt-4",
+ title="T",
+ working_directory="/",
+ pinned=False,
+ )
+
+ call_kwargs = mock_write.call_args.kwargs
+ # created_at should be an ISO datetime string, not an error
+ created_at = call_kwargs["created_at"]
+ assert isinstance(created_at, str)
+ datetime.datetime.fromisoformat(created_at) # must be valid ISO
+
+
+@pytest.mark.asyncio
+async def test_ctx_none_uses_now_iso():
+ """ctx=None should fall back to now_iso for created_at."""
+ from code_puppy.api.ws.background_save import save_agent_result_in_background
+
+ msg = _bare_msg()
+ task_future = asyncio.get_event_loop().create_future()
+ task_future.set_result(_make_result(messages=[msg]))
+ agent = _make_agent(history=[msg])
+
+ with (
+ patch(
+ "code_puppy.api.ws.background_save.session_exists", new_callable=AsyncMock
+ ) as mock_exists,
+ patch(
+ "code_puppy.api.ws.background_save.write_turn_to_sqlite",
+ new_callable=AsyncMock,
+ ) as mock_write,
+ ):
+ mock_exists.return_value = True
+ await save_agent_result_in_background(
+ agent_task=task_future,
+ session_id="sess-ctx-none",
+ ctx=None,
+ agent=agent,
+ agent_name="puppy",
+ model_name="gpt-4",
+ title="T",
+ working_directory="/",
+ pinned=False,
+ )
+
+ call_kwargs = mock_write.call_args.kwargs
+ created_at = call_kwargs["created_at"]
+ assert isinstance(created_at, str)
+ datetime.datetime.fromisoformat(created_at)
+
+
+# ---------------------------------------------------------------------------
+# History wrapping
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_pre_wrapped_dict_entries_pass_through():
+ """Dicts with 'msg' key must not be double-wrapped."""
+ from code_puppy.api.ws.background_save import save_agent_result_in_background
+
+ wrapped = _wrapped_msg()
+ task_future = asyncio.get_event_loop().create_future()
+ task_future.set_result(_make_result(messages=[wrapped]))
+ agent = _make_agent(history=[wrapped])
+
+ with (
+ patch(
+ "code_puppy.api.ws.background_save.session_exists", new_callable=AsyncMock
+ ) as mock_exists,
+ patch(
+ "code_puppy.api.ws.background_save.write_turn_to_sqlite",
+ new_callable=AsyncMock,
+ ) as mock_write,
+ ):
+ mock_exists.return_value = True
+ await save_agent_result_in_background(
+ agent_task=task_future,
+ session_id="sess-wrapped",
+ ctx=None,
+ agent=agent,
+ agent_name="puppy",
+ model_name="gpt-4",
+ title="T",
+ working_directory="/",
+ pinned=False,
+ )
+
+ eh = mock_write.call_args.kwargs["enhanced_history"]
+ assert len(eh) == 1
+ assert eh[0] is wrapped # same object, not a new wrapper
+
+
+@pytest.mark.asyncio
+async def test_bare_message_gets_wrapped():
+ """Bare ModelMessage objects must be wrapped with agent/model/ts metadata."""
+ from code_puppy.api.ws.background_save import save_agent_result_in_background
+
+ bare = _bare_msg()
+ task_future = asyncio.get_event_loop().create_future()
+ task_future.set_result(_make_result(messages=[bare]))
+ agent = _make_agent(history=[bare])
+
+ with (
+ patch(
+ "code_puppy.api.ws.background_save.session_exists", new_callable=AsyncMock
+ ) as mock_exists,
+ patch(
+ "code_puppy.api.ws.background_save.write_turn_to_sqlite",
+ new_callable=AsyncMock,
+ ) as mock_write,
+ ):
+ mock_exists.return_value = True
+ await save_agent_result_in_background(
+ agent_task=task_future,
+ session_id="sess-bare",
+ ctx=None,
+ agent=agent,
+ agent_name="my-agent",
+ model_name="gpt-4o",
+ title="T",
+ working_directory="/",
+ pinned=False,
+ )
+
+ eh = mock_write.call_args.kwargs["enhanced_history"]
+ assert len(eh) == 1
+ wrapper = eh[0]
+ assert wrapper["msg"] is bare
+ assert wrapper["agent"] == "my-agent"
+ assert wrapper["model"] == "gpt-4o"
+ assert "ts" in wrapper
+
+
+# ---------------------------------------------------------------------------
+# Token computation
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_token_count_computed_not_zero():
+ """total_tokens must reflect the actual estimate, not hard-coded 0."""
+ from code_puppy.api.ws.background_save import save_agent_result_in_background
+
+ bare = _bare_msg()
+ task_future = asyncio.get_event_loop().create_future()
+ task_future.set_result(_make_result(messages=[bare]))
+ agent = _make_agent(history=[bare], token_per_msg=42)
+
+ with (
+ patch(
+ "code_puppy.api.ws.background_save.session_exists", new_callable=AsyncMock
+ ) as mock_exists,
+ patch(
+ "code_puppy.api.ws.background_save.write_turn_to_sqlite",
+ new_callable=AsyncMock,
+ ) as mock_write,
+ ):
+ mock_exists.return_value = True
+ await save_agent_result_in_background(
+ agent_task=task_future,
+ session_id="sess-tokens",
+ ctx=None,
+ agent=agent,
+ agent_name="puppy",
+ model_name="gpt-4",
+ title="T",
+ working_directory="/",
+ pinned=False,
+ )
+
+ total_tokens = mock_write.call_args.kwargs["total_tokens"]
+ assert total_tokens == 42 # 1 message × 42 tokens each
+ agent.estimate_tokens_for_message.assert_called_once()
+
+
+# ---------------------------------------------------------------------------
+# Happy-path write args
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_happy_path_write_kwargs():
+ """write_turn_to_sqlite must receive the correct session metadata."""
+ from code_puppy.api.ws.background_save import save_agent_result_in_background
+
+ created = datetime.datetime(2025, 1, 1, tzinfo=datetime.timezone.utc)
+ ctx = SimpleNamespace(created_at=created)
+
+ bare = _bare_msg()
+ task_future = asyncio.get_event_loop().create_future()
+ task_future.set_result(_make_result(messages=[bare]))
+ agent = _make_agent(history=[bare], token_per_msg=5)
+
+ with (
+ patch(
+ "code_puppy.api.ws.background_save.session_exists", new_callable=AsyncMock
+ ) as mock_exists,
+ patch(
+ "code_puppy.api.ws.background_save.write_turn_to_sqlite",
+ new_callable=AsyncMock,
+ ) as mock_write,
+ ):
+ mock_exists.return_value = True
+ await save_agent_result_in_background(
+ agent_task=task_future,
+ session_id="sess-happy",
+ ctx=ctx,
+ agent=agent,
+ agent_name="code-puppy",
+ model_name="claude-3",
+ title="My Session",
+ working_directory="/home/user",
+ pinned=True,
+ label="switch",
+ )
+
+ kw = mock_write.call_args.kwargs
+ assert kw["session_id"] == "sess-happy"
+ assert kw["agent_name"] == "code-puppy"
+ assert kw["model_name"] == "claude-3"
+ assert kw["title"] == "My Session"
+ assert kw["working_directory"] == "/home/user"
+ assert kw["pinned"] is True
+ assert kw["created_at"] == created.isoformat()
+ assert kw["total_tokens"] == 5
+ assert kw["ctx"] is ctx
diff --git a/code_puppy/api/ws/tests/test_event_driven_drain.py b/code_puppy/api/ws/tests/test_event_driven_drain.py
new file mode 100644
index 000000000..ce74cc537
--- /dev/null
+++ b/code_puppy/api/ws/tests/test_event_driven_drain.py
@@ -0,0 +1,556 @@
+"""Tests for event-driven polling elimination in WebSocket handlers.
+
+Verifies that the polling-based event collection has been replaced with
+event-driven asyncio.wait_for() approach. Tests validate:
+
+1. Events are received without time-based polling
+2. Batching efficiency is maintained
+3. Timeout handling works gracefully
+4. Empty queue behavior is correct
+5. Performance improvement (no busy-wait loops)
+"""
+
+import asyncio
+import time
+from typing import Any
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+
+class EventDrivenCollector:
+ """Reference implementation of event-driven batch collection.
+
+ This mimics the pattern used in chat_handler.py
+ after the polling elimination fix.
+ """
+
+ def __init__(self, event_queue: asyncio.Queue):
+ self.event_queue = event_queue
+ self.event_count = 0
+
+ async def collect_batch(self) -> list[dict[str, Any]]:
+ """Collect a batch of events using event-driven approach.
+
+ Returns:
+ List of events collected (may be empty if timeout occurs)
+ """
+ events_to_send = []
+ try:
+ # Wait for first event with 10ms timeout (blocks efficiently)
+ first_event = await asyncio.wait_for(self.event_queue.get(), timeout=0.01)
+ events_to_send.append(first_event)
+ self.event_count += 1
+
+ # Collect any additional events already in queue (non-blocking)
+ # This keeps the batching benefit without polling the clock
+ while not self.event_queue.empty():
+ try:
+ event = self.event_queue.get_nowait()
+ events_to_send.append(event)
+ self.event_count += 1
+ except Exception:
+ break
+
+ except asyncio.TimeoutError:
+ # No events available within 10ms timeout
+ # No polling needed - asyncio.wait_for blocks efficiently
+ pass
+
+ return events_to_send
+
+
+# ============================================================================
+# FIXTURES
+# ============================================================================
+
+
+@pytest.fixture
+async def event_queue():
+ """Fixture providing a fresh asyncio.Queue for each test."""
+ return asyncio.Queue()
+
+
+@pytest.fixture
+def collector(event_queue):
+ """Fixture providing an EventDrivenCollector instance."""
+ return EventDrivenCollector(event_queue)
+
+
+# ============================================================================
+# TEST: Events are received without polling
+# ============================================================================
+
+
+@pytest.mark.asyncio
+async def test_events_received_without_time_polling():
+ """Verify events are received using asyncio.wait_for, not time-based polling.
+
+ This test ensures that we've eliminated the busy-wait polling loop
+ that used time.monotonic() or time.time() to check elapsed time.
+
+ The event-driven approach should:
+ - Block efficiently on event_queue.get()
+ - Use asyncio.wait_for() for timeout
+ - Not poll the clock in a loop
+ """
+ queue = asyncio.Queue()
+ collector = EventDrivenCollector(queue)
+
+ # Add an event to the queue
+ test_event = {"type": "test", "data": "hello"}
+ await queue.put(test_event)
+
+ # Collect batch - should return the event immediately
+ start = time.perf_counter()
+ batch = await collector.collect_batch()
+ elapsed = time.perf_counter() - start
+
+ # Verify event was received
+ assert len(batch) == 1
+ assert batch[0] == test_event
+ assert collector.event_count == 1
+
+ # Verify it happened nearly instantly (< 5ms, not 10ms sleep)
+ # If it was using time.sleep(0.01), it would take ~10ms
+ assert elapsed < 0.005, f"Collection took {elapsed * 1000:.2f}ms (should be <5ms)"
+
+
+@pytest.mark.asyncio
+async def test_wait_for_timeout_called(event_queue, collector):
+ """Verify asyncio.wait_for() is being used for timeout.
+
+ Mock asyncio.wait_for to verify it's called, confirming we're using
+ the efficient async timeout mechanism instead of time-based polling.
+ """
+ with patch("asyncio.wait_for", new_callable=AsyncMock) as mock_wait_for:
+ # Setup mock to raise TimeoutError
+ mock_wait_for.side_effect = asyncio.TimeoutError()
+
+ # Collect batch
+ batch = await collector.collect_batch()
+
+ # Verify wait_for was called
+ mock_wait_for.assert_called_once()
+ call_args = mock_wait_for.call_args
+
+ # Verify timeout parameter (should be 0.01 = 10ms)
+ assert call_args.kwargs.get("timeout") == 0.01, "Should use 10ms timeout"
+
+ # Verify returned empty batch on timeout
+ assert len(batch) == 0
+
+
+@pytest.mark.asyncio
+async def test_no_time_module_polling():
+ """Verify time module is not used for polling.
+
+ The old implementation used time.monotonic() or time.time() to poll
+ in a loop. The new implementation should not do this.
+
+ Instead of mocking (which breaks asyncio), we verify the code doesn't
+ have polling loops by checking it uses wait_for() instead.
+ """
+ queue = asyncio.Queue()
+ collector = EventDrivenCollector(queue)
+
+ # Add event
+ await queue.put({"type": "test"})
+
+ # Collect batch
+ batch = await collector.collect_batch()
+
+ # If we're here without exception, the pattern is working
+ # (No time-based polling loop would cause issues with asyncio)
+ assert len(batch) == 1
+ assert batch[0]["type"] == "test"
+
+
+# ============================================================================
+# TEST: Batching efficiency maintained
+# ============================================================================
+
+
+@pytest.mark.asyncio
+async def test_multiple_events_batched_together():
+ """Verify multiple events arriving together are collected in one batch.
+
+ The event-driven approach should maintain the batching benefit:
+ when multiple events arrive within the 10ms window, they should be
+ collected together in a single batch.
+ """
+ queue = asyncio.Queue()
+ collector = EventDrivenCollector(queue)
+
+ # Add multiple events to queue
+ events = [
+ {"type": "tool_call_start", "tool_name": "tool1"},
+ {"type": "tool_result", "result": "result1"},
+ {"type": "stream_event", "data": "text"},
+ {"type": "tool_call_complete"},
+ ]
+ for event in events:
+ await queue.put(event)
+
+ # Collect batch
+ batch = await collector.collect_batch()
+
+ # All events should be in one batch
+ assert len(batch) == 4, "All events should be in single batch"
+ assert batch == events
+ assert collector.event_count == 4
+
+
+@pytest.mark.asyncio
+async def test_batching_with_sequential_additions():
+ """Verify batching when events are added: first immediately, then more.
+
+ Simulates real-world scenario where:
+ 1. First event arrives immediately
+ 2. Additional events arrive quickly after
+ 3. All should be collected in one batch
+ """
+ queue = asyncio.Queue()
+ collector = EventDrivenCollector(queue)
+
+ # Add first event
+ await queue.put({"type": "event1"})
+
+ # Simulate additional events arriving quickly
+ async def add_more_events():
+ await asyncio.sleep(0.002) # 2ms later
+ await queue.put({"type": "event2"})
+ await queue.put({"type": "event3"})
+
+ # Start adding more events concurrently
+ task = asyncio.create_task(add_more_events())
+
+ # Collect batch (should wait 10ms, allowing time for more events)
+ batch = await collector.collect_batch()
+ await task
+
+ # Should have collected first + any others that arrived
+ assert len(batch) >= 1, "Should collect at least first event"
+ assert batch[0]["type"] == "event1"
+
+
+@pytest.mark.asyncio
+async def test_event_count_tracking():
+ """Verify event_count is incremented correctly for batched events."""
+ queue = asyncio.Queue()
+ collector = EventDrivenCollector(queue)
+
+ assert collector.event_count == 0
+
+ # First batch
+ await queue.put({"type": "event1"})
+ await queue.put({"type": "event2"})
+ batch1 = await collector.collect_batch()
+ assert len(batch1) == 2
+ assert collector.event_count == 2
+
+ # Second batch (after timeout)
+ batch2 = await collector.collect_batch()
+ assert len(batch2) == 0
+ assert collector.event_count == 2 # Unchanged
+
+
+# ============================================================================
+# TEST: Timeout handling
+# ============================================================================
+
+
+@pytest.mark.asyncio
+async def test_timeout_handled_gracefully():
+ """Verify TimeoutError is caught and handled gracefully.
+
+ When no events are available within the 10ms timeout window,
+ the asyncio.TimeoutError should be caught and an empty batch returned.
+ No exceptions should bubble up.
+ """
+ queue = asyncio.Queue()
+ collector = EventDrivenCollector(queue)
+
+ # Queue is empty, so timeout will occur
+ batch = await collector.collect_batch()
+
+ # Should return empty batch
+ assert len(batch) == 0
+ assert isinstance(batch, list)
+
+ # Should not raise any exception
+ assert True # If we got here without exception, test passed
+
+
+@pytest.mark.asyncio
+async def test_loop_continues_after_timeout():
+ """Verify loop can continue after timeout without issues.
+
+ Multiple collection attempts should work correctly even if
+ some attempts timeout.
+ """
+ queue = asyncio.Queue()
+ collector = EventDrivenCollector(queue)
+
+ # First collection - timeout
+ batch1 = await collector.collect_batch()
+ assert len(batch1) == 0
+
+ # Add event
+ await queue.put({"type": "event1"})
+
+ # Second collection - should get event
+ batch2 = await collector.collect_batch()
+ assert len(batch2) == 1
+ assert batch2[0]["type"] == "event1"
+
+ # Third collection - timeout again
+ batch3 = await collector.collect_batch()
+ assert len(batch3) == 0
+
+
+@pytest.mark.asyncio
+async def test_timeout_value_correct(event_queue):
+ """Verify the timeout value is exactly 10ms (0.01 seconds).
+
+ The old polling approach used a 10ms window. The new approach
+ should maintain this same responsiveness window.
+ """
+ collector = EventDrivenCollector(event_queue)
+
+ # Measure actual timeout duration
+ start = time.perf_counter()
+ _ = await collector.collect_batch() # Intentionally discarding batch
+ elapsed = time.perf_counter() - start
+
+ # With no events, should timeout after ~10ms (±2ms tolerance)
+ assert 0.008 < elapsed < 0.015, (
+ f"Timeout should be ~10ms, got {elapsed * 1000:.2f}ms"
+ )
+
+
+# ============================================================================
+# TEST: Empty queue behavior
+# ============================================================================
+
+
+@pytest.mark.asyncio
+async def test_empty_queue_returns_empty_batch():
+ """Verify empty queue results in empty batch.
+
+ When queue is empty and timeout occurs, should return empty list,
+ not raise an exception.
+ """
+ queue = asyncio.Queue()
+ collector = EventDrivenCollector(queue)
+ batch = await collector.collect_batch()
+ assert batch == []
+
+
+@pytest.mark.asyncio
+async def test_no_infinite_loop_on_empty_queue():
+ """Verify no infinite loop when queue is empty.
+
+ The timeout should prevent infinite looping. Collection should
+ complete in ~10ms even with empty queue.
+ """
+ queue = asyncio.Queue()
+ collector = EventDrivenCollector(queue)
+
+ start = time.perf_counter()
+ batch = await collector.collect_batch()
+ elapsed = time.perf_counter() - start
+
+ # Should complete quickly (within timeout + small overhead)
+ assert elapsed < 0.05, f"Should not hang, took {elapsed * 1000:.2f}ms"
+ assert batch == []
+
+
+@pytest.mark.asyncio
+async def test_queue_empty_check_works():
+ """Verify queue.empty() check works correctly for secondary collection.
+
+ After getting first event, the code checks event_queue.empty() to
+ collect additional events. This should work correctly.
+ """
+ queue = asyncio.Queue()
+ collector = EventDrivenCollector(queue)
+
+ # Add multiple events
+ await queue.put({"type": "event1"})
+ await queue.put({"type": "event2"})
+ await queue.put({"type": "event3"})
+
+ batch = await collector.collect_batch()
+
+ # All events should be collected despite using empty() check
+ assert len(batch) == 3
+ assert collector.event_count == 3
+
+ # Queue should now be empty
+ assert queue.empty()
+
+
+# ============================================================================
+# TEST: Performance improvement (no busy-wait loops)
+# ============================================================================
+
+
+@pytest.mark.asyncio
+async def test_performance_event_arrival_near_instant():
+ """Verify event collection happens near-instantly (<1ms).
+
+ The old polling approach would wait up to 10ms before checking
+ the queue. Event-driven approach wakes immediately on event arrival.
+ """
+ queue = asyncio.Queue()
+ collector = EventDrivenCollector(queue)
+
+ # Add event then immediately collect
+ await queue.put({"type": "test"})
+
+ start = time.perf_counter()
+ batch = await collector.collect_batch()
+ elapsed = time.perf_counter() - start
+
+ # Should be near-instant (<1ms, not ~10ms)
+ assert elapsed < 0.001, (
+ f"Event should be collected near-instantly, "
+ f"took {elapsed * 1000:.2f}ms (should be <1ms)"
+ )
+ assert len(batch) == 1
+
+
+@pytest.mark.asyncio
+async def test_no_sleep_on_every_iteration():
+ """Verify we don't sleep 10ms on every loop iteration.
+
+ The old implementation would do: if not events: await asyncio.sleep(0.01)
+ This test verifies that pattern is eliminated.
+ """
+ queue = asyncio.Queue()
+ collector = EventDrivenCollector(queue)
+
+ # Track asyncio.sleep calls
+ with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
+ # Run collection multiple times
+ for _ in range(3):
+ await collector.collect_batch()
+
+ # Should NOT call asyncio.sleep(0.01) on idle
+ # (It might be called elsewhere, but not as idle sleep)
+ sleep_calls = [call for call in mock_sleep.call_args_list]
+ idle_sleep_calls = [call for call in sleep_calls if call[0] == (0.01,)]
+ assert len(idle_sleep_calls) == 0, (
+ "Should not sleep 0.01s on every idle iteration"
+ )
+
+
+@pytest.mark.asyncio
+async def test_multiple_events_processed_once():
+ """Verify multiple events are processed in one batch, not multiple sleeps.
+
+ Old approach: each iteration could sleep 10ms if no events
+ New approach: waits 10ms once, collects all available events
+ """
+ queue = asyncio.Queue()
+ collector = EventDrivenCollector(queue)
+
+ # Add events
+ for i in range(5):
+ await queue.put({"type": "event", "id": i})
+
+ start = time.perf_counter()
+ batch = await collector.collect_batch()
+ elapsed = time.perf_counter() - start
+
+ # All events collected in one batch
+ assert len(batch) == 5
+ # Should be instant, not 50ms (5 iterations * 10ms sleep)
+ assert elapsed < 0.005
+
+
+@pytest.mark.asyncio
+async def test_batching_efficiency_metric():
+ """Calculate and verify batching efficiency.
+
+ Batching efficiency = total_events / number_of_batches
+
+ Event-driven approach should achieve good batching even with
+ sequential additions, because it waits up to 10ms for additional events.
+ """
+ queue = asyncio.Queue()
+ collector = EventDrivenCollector(queue)
+
+ batch_sizes = []
+
+ # Simulate 3 collection cycles
+ for cycle in range(3):
+ # Add varying number of events
+ num_events = cycle + 1 # 1, 2, 3
+ for i in range(num_events):
+ await queue.put({"type": "event", "cycle": cycle, "id": i})
+
+ batch = await collector.collect_batch()
+ batch_sizes.append(len(batch))
+
+ # Verify we collected events (not all empty)
+ total_collected = sum(batch_sizes)
+ assert total_collected == 6, "Should collect all 6 events (1+2+3)"
+
+ # Calculate efficiency: 6 events / 3 batches = 2.0 average
+ avg_batch_size = total_collected / 3
+ assert avg_batch_size >= 1.5, "Should have reasonable batching"
+
+
+# ============================================================================
+# INTEGRATION: Full drain loop simulation
+# ============================================================================
+
+
+@pytest.mark.asyncio
+async def test_drain_loop_simulation():
+ """Simulate a full drain loop with event-driven collection.
+
+ This mimics the actual usage in chat_handler.py:
+ - Collect events in a loop
+ - Process collected events
+ - Continue until stop signal
+ """
+ queue = asyncio.Queue()
+ collector = EventDrivenCollector(queue)
+ stop_event = asyncio.Event()
+ processed_events = []
+
+ async def drain_loop():
+ """Simulate the drain event loop."""
+ while not stop_event.is_set():
+ batch = await collector.collect_batch()
+ for event in batch:
+ processed_events.append(event)
+
+ # Add small delay to allow test to add events
+ if not batch:
+ await asyncio.sleep(0.001)
+
+ # Start drain loop
+ drain_task = asyncio.create_task(drain_loop())
+
+ # Simulate events being added
+ await asyncio.sleep(0.01)
+ for i in range(5):
+ await queue.put({"type": "event", "id": i})
+
+ await asyncio.sleep(0.05) # Let drain collect them
+
+ # Stop the loop
+ stop_event.set()
+ await asyncio.wait_for(drain_task, timeout=1.0)
+
+ # Verify all events were processed
+ assert len(processed_events) == 5
+ assert all(e["type"] == "event" for e in processed_events)
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v", "--tb=short"])
diff --git a/code_puppy/api/ws/tests/test_event_driven_polling.py b/code_puppy/api/ws/tests/test_event_driven_polling.py
new file mode 100644
index 000000000..13b90799d
--- /dev/null
+++ b/code_puppy/api/ws/tests/test_event_driven_polling.py
@@ -0,0 +1,337 @@
+"""Tests for event-driven polling elimination (Phases 1-2 fixes).
+
+Verifies that:
+1. Events are collected without polling loops
+2. Batching still works efficiently
+3. Timeout handling works gracefully
+4. Event ordering is preserved
+5. No regressions in event processing
+"""
+
+import asyncio
+
+import pytest
+
+
+@pytest.mark.asyncio
+async def test_event_driven_collection_no_polling():
+ """Verify events are collected using asyncio.wait_for, not polling loops.
+
+ This test ensures that the event-driven approach (Phase 1 & 2) correctly
+ uses asyncio.wait_for() instead of busy-waiting with time.monotonic().
+ """
+ # Create a queue and emit events
+ event_queue: asyncio.Queue = asyncio.Queue()
+
+ # Add events to the queue
+ await event_queue.put({"type": "content", "data": {"text": "Hello"}})
+ await event_queue.put({"type": "content", "data": {"text": " World"}})
+
+ # Simulate the event-driven collection pattern from fixed handlers
+ events_to_send = []
+ try:
+ # Wait for first event with 10ms timeout (event-driven, not polling)
+ first_event = await asyncio.wait_for(event_queue.get(), timeout=0.01)
+ events_to_send.append(first_event)
+
+ # Collect any additional events already queued (non-blocking)
+ while not event_queue.empty():
+ try:
+ events_to_send.append(event_queue.get_nowait())
+ except Exception:
+ break
+
+ except asyncio.TimeoutError:
+ pass
+
+ # Verify both events were collected
+ assert len(events_to_send) == 2
+ assert events_to_send[0]["data"]["text"] == "Hello"
+ assert events_to_send[1]["data"]["text"] == " World"
+
+
+@pytest.mark.asyncio
+async def test_event_driven_timeout_handling():
+ """Verify timeout handling when no events are available.
+
+ This test ensures asyncio.TimeoutError is properly caught
+ and doesn't cause the handler to crash.
+ """
+ # Empty queue
+ event_queue: asyncio.Queue = asyncio.Queue()
+
+ # Simulate event-driven collection with very short timeout
+ events_to_send = []
+ try:
+ first_event = await asyncio.wait_for(event_queue.get(), timeout=0.001)
+ events_to_send.append(first_event)
+ except asyncio.TimeoutError:
+ # Expected - no events in queue
+ pass
+
+ # No events should be collected
+ assert len(events_to_send) == 0
+
+
+@pytest.mark.asyncio
+async def test_batching_preserved_with_event_driven():
+ """Verify batching efficiency is preserved in event-driven approach.
+
+ The 10ms batching window should still collect multiple events
+ without introducing polling overhead.
+ """
+ event_queue: asyncio.Queue = asyncio.Queue()
+
+ # Emit multiple events rapidly
+ for i in range(5):
+ await event_queue.put({"type": "event", "data": {"index": i}})
+
+ # Collect all events in one batch using event-driven approach
+ events_to_send = []
+ try:
+ first_event = await asyncio.wait_for(event_queue.get(), timeout=0.1)
+ events_to_send.append(first_event)
+
+ while not event_queue.empty():
+ try:
+ events_to_send.append(event_queue.get_nowait())
+ except Exception:
+ break
+
+ except asyncio.TimeoutError:
+ pass
+
+ # All 5 events should be collected in a single batch
+ assert len(events_to_send) == 5
+ for i in range(5):
+ assert events_to_send[i]["data"]["index"] == i
+
+
+@pytest.mark.asyncio
+async def test_event_ordering_preserved():
+ """Verify event ordering is preserved in batches.
+
+ Events should be processed in the order they were emitted.
+ """
+ event_queue: asyncio.Queue = asyncio.Queue()
+
+ # Emit events with order markers
+ for i in range(10):
+ await event_queue.put(
+ {"type": "ordered_event", "data": {"sequence": i, "timestamp": i * 1000}}
+ )
+
+ # Collect events
+ events_to_send = []
+ try:
+ first_event = await asyncio.wait_for(event_queue.get(), timeout=0.1)
+ events_to_send.append(first_event)
+
+ while not event_queue.empty():
+ try:
+ events_to_send.append(event_queue.get_nowait())
+ except Exception:
+ break
+
+ except asyncio.TimeoutError:
+ pass
+
+ # Verify all events collected and in order
+ assert len(events_to_send) == 10
+ for i, event in enumerate(events_to_send):
+ assert event["data"]["sequence"] == i
+ assert event["data"]["timestamp"] == i * 1000
+
+
+@pytest.mark.asyncio
+async def test_interleaved_event_production():
+ """Test handling of events added during collection.
+
+ Simulates realistic scenario where events arrive both before
+ and after timeout in the wait_for() call.
+ """
+ event_queue: asyncio.Queue = asyncio.Queue()
+
+ # Add initial events
+ await event_queue.put({"type": "pre", "data": {"phase": "before"}})
+
+ async def add_event_later():
+ """Add event after a small delay."""
+ await asyncio.sleep(0.005)
+ await event_queue.put({"type": "post", "data": {"phase": "after"}})
+
+ # Start background task to add event during collection
+ task = asyncio.create_task(add_event_later())
+
+ # Collect events with longer timeout
+ events_to_send = []
+ try:
+ first_event = await asyncio.wait_for(event_queue.get(), timeout=0.05)
+ events_to_send.append(first_event)
+
+ while not event_queue.empty():
+ try:
+ events_to_send.append(event_queue.get_nowait())
+ except Exception:
+ break
+
+ except asyncio.TimeoutError:
+ pass
+
+ await task
+
+ # Should have at least the first event
+ assert len(events_to_send) >= 1
+ assert events_to_send[0]["data"]["phase"] == "before"
+
+
+@pytest.mark.asyncio
+async def test_no_cpu_waste_on_idle():
+ """Verify that idle timeouts don't cause busy-waiting.
+
+ The old polling approach used await asyncio.sleep(0.01) which still
+ wasted CPU. The new approach uses asyncio.wait_for() which properly
+ suspends the task without CPU usage.
+ """
+ event_queue: asyncio.Queue = asyncio.Queue()
+
+ # Record timing of timeout
+ start_time = asyncio.get_event_loop().time()
+
+ events_to_send = []
+ try:
+ # This should block efficiently for ~10ms, not busy-wait
+ first_event = await asyncio.wait_for(event_queue.get(), timeout=0.01)
+ events_to_send.append(first_event)
+ except asyncio.TimeoutError:
+ pass
+
+ elapsed = asyncio.get_event_loop().time() - start_time
+
+ # Should take approximately 10ms, not significantly less or more
+ # (we allow some variance for system scheduling)
+ assert 0.008 < elapsed < 0.05, f"Timeout took {elapsed}s, expected ~0.01s"
+ assert len(events_to_send) == 0
+
+
+@pytest.mark.asyncio
+async def test_rapid_fire_events_single_batch():
+ """Test that rapidly fired events are collected in a single batch.
+
+ This is key to the efficiency gains - multiple events should be
+ processed together without individual polling cycles.
+ """
+ event_queue: asyncio.Queue = asyncio.Queue()
+
+ # Emit 100 events rapidly
+ for i in range(100):
+ await event_queue.put({"type": "rapid", "data": {"id": i}})
+
+ # Collect all in single cycle
+ events_to_send = []
+ try:
+ first_event = await asyncio.wait_for(event_queue.get(), timeout=0.1)
+ events_to_send.append(first_event)
+
+ while not event_queue.empty():
+ try:
+ events_to_send.append(event_queue.get_nowait())
+ except Exception:
+ break
+
+ except asyncio.TimeoutError:
+ pass
+
+ # All events should be collected
+ assert len(events_to_send) == 100
+ # Verify we got them in order
+ for i in range(100):
+ assert events_to_send[i]["data"]["id"] == i
+
+
+@pytest.mark.asyncio
+async def test_mixed_event_types_preserved():
+ """Test that different event types are preserved in batches.
+
+ Various event types (tool_call, content, stream_event, etc.) should
+ all be collected and processed correctly.
+ """
+ event_queue: asyncio.Queue = asyncio.Queue()
+
+ # Emit mixed event types
+ event_types = [
+ {"type": "tool_call_start", "data": {"tool": "search"}},
+ {"type": "content", "data": {"text": "Query..."}},
+ {"type": "tool_call_complete", "data": {"result": "found"}},
+ {"type": "stream_event", "data": {"event_type": "part_delta"}},
+ {"type": "message_complete", "data": {"final": True}},
+ ]
+
+ for event in event_types:
+ await event_queue.put(event)
+
+ # Collect all events
+ events_to_send = []
+ try:
+ first_event = await asyncio.wait_for(event_queue.get(), timeout=0.1)
+ events_to_send.append(first_event)
+
+ while not event_queue.empty():
+ try:
+ events_to_send.append(event_queue.get_nowait())
+ except Exception:
+ break
+
+ except asyncio.TimeoutError:
+ pass
+
+ # All events collected
+ assert len(events_to_send) == 5
+ # Verify types match in order
+ for i, expected in enumerate(event_types):
+ assert events_to_send[i]["type"] == expected["type"]
+
+
+@pytest.mark.asyncio
+async def test_timeout_with_partial_events():
+ """Test partial collection when timeout occurs mid-batch.
+
+ If events come in after wait_for timeout, the handler should
+ gracefully handle the empty batch and try again.
+ """
+ event_queue: asyncio.Queue = asyncio.Queue()
+
+ # First collection - empty
+ events_batch_1 = []
+ try:
+ first_event = await asyncio.wait_for(event_queue.get(), timeout=0.005)
+ events_batch_1.append(first_event)
+ except asyncio.TimeoutError:
+ pass
+
+ # First batch should be empty
+ assert len(events_batch_1) == 0
+
+ # Add events
+ await event_queue.put({"type": "event1", "data": {}})
+ await event_queue.put({"type": "event2", "data": {}})
+
+ # Second collection - should get both
+ events_batch_2 = []
+ try:
+ first_event = await asyncio.wait_for(event_queue.get(), timeout=0.05)
+ events_batch_2.append(first_event)
+
+ while not event_queue.empty():
+ try:
+ events_batch_2.append(event_queue.get_nowait())
+ except Exception:
+ break
+
+ except asyncio.TimeoutError:
+ pass
+
+ # Second batch should have both events
+ assert len(events_batch_2) == 2
+ assert events_batch_2[0]["type"] == "event1"
+ assert events_batch_2[1]["type"] == "event2"
diff --git a/code_puppy/api/ws/tests/test_tool_group_id_consistency.py b/code_puppy/api/ws/tests/test_tool_group_id_consistency.py
new file mode 100644
index 000000000..7175f67b0
--- /dev/null
+++ b/code_puppy/api/ws/tests/test_tool_group_id_consistency.py
@@ -0,0 +1,67 @@
+"""Regression tests for tool_group_id consistency in websocket lifecycle frames.
+
+These tests enforce the backend invariant introduced by Option C:
+all emitted ``ServerToolResult`` payloads must include ``tool_group_id``.
+"""
+
+from __future__ import annotations
+
+import ast
+from pathlib import Path
+
+CHAT_HANDLER_PATH = Path(__file__).resolve().parents[1] / "chat_handler.py"
+
+
+def _get_server_tool_result_calls(source: str) -> list[ast.Call]:
+ """Return every ``ServerToolResult(...)`` call in chat_handler source."""
+ tree = ast.parse(source)
+ calls: list[ast.Call] = []
+
+ for node in ast.walk(tree):
+ if not isinstance(node, ast.Call):
+ continue
+ if isinstance(node.func, ast.Name) and node.func.id == "ServerToolResult":
+ calls.append(node)
+
+ return calls
+
+
+def test_server_tool_result_calls_always_include_tool_group_id_keyword() -> None:
+ """Every ServerToolResult constructor call must include tool_group_id.
+
+ This protects against regressions where a new emission path forgets to
+ include the grouping field and breaks frontend tool-message grouping.
+ """
+ source = CHAT_HANDLER_PATH.read_text(encoding="utf-8")
+ calls = _get_server_tool_result_calls(source)
+
+ assert calls, "Expected at least one ServerToolResult(...) call"
+
+ missing = []
+ for call in calls:
+ keyword_names = {kw.arg for kw in call.keywords if kw.arg is not None}
+ if "tool_group_id" not in keyword_names:
+ missing.append(call.lineno)
+
+ assert not missing, (
+ f"ServerToolResult(...) calls missing tool_group_id keyword at lines: {missing}"
+ )
+
+
+def test_server_tool_result_calls_never_pass_explicit_none_for_tool_group_id() -> None:
+ """Guard against explicit ``tool_group_id=None`` regressions."""
+ source = CHAT_HANDLER_PATH.read_text(encoding="utf-8")
+ calls = _get_server_tool_result_calls(source)
+
+ explicit_none_lines: list[int] = []
+ for call in calls:
+ for keyword in call.keywords:
+ if keyword.arg != "tool_group_id":
+ continue
+ if isinstance(keyword.value, ast.Constant) and keyword.value.value is None:
+ explicit_none_lines.append(call.lineno)
+
+ assert not explicit_none_lines, (
+ "ServerToolResult(...) calls pass explicit tool_group_id=None at lines: "
+ f"{explicit_none_lines}"
+ )
From 1e5bf344e34e8ddf1d863ba85d67857c1f1d32aa Mon Sep 17 00:00:00 2001
From: shravya maddipudi
Date: Thu, 28 May 2026 18:38:52 -0500
Subject: [PATCH 07/29] feat(core): migrate backend logging and model error
normalization modules
---
code_puppy/logging_setup.py | 223 +++++++++++++++++++++++++++++
code_puppy/model_errors.py | 277 ++++++++++++++++++++++++++++++++++++
2 files changed, 500 insertions(+)
create mode 100644 code_puppy/logging_setup.py
create mode 100644 code_puppy/model_errors.py
diff --git a/code_puppy/logging_setup.py b/code_puppy/logging_setup.py
new file mode 100644
index 000000000..747546160
--- /dev/null
+++ b/code_puppy/logging_setup.py
@@ -0,0 +1,223 @@
+"""Backend logging configuration utilities.
+
+Provides date-based file logging with weekday in filename, stdout logging,
+and retention cleanup for backend log files.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+import re
+import sys
+from datetime import date, datetime, timedelta
+from pathlib import Path
+from typing import Any
+
+from code_puppy.config import STATE_DIR
+
+_BACKEND_FILE_RE = re.compile(
+ r"^backend-(monday|tuesday|wednesday|thursday|friday|saturday|sunday)-(\d{4}-\d{2}-\d{2})\.log$"
+)
+_DEFAULT_FORMAT = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
+_DEFAULT_DATEFMT = "%H:%M:%S"
+
+
+class DailyBackendFileHandler(logging.Handler):
+ """File handler that switches to a new date-based file at midnight.
+
+ Filename format:
+ backend--YYYY-MM-DD.log
+ """
+
+ def __init__(self, log_dir: Path, retention_days: int, encoding: str = "utf-8"):
+ super().__init__()
+ self.log_dir = log_dir
+ self.retention_days = max(0, retention_days)
+ self.encoding = encoding
+ self.terminator = "\n"
+ self._stream: Any = None
+ self._active_date: date | None = None
+ self._active_path: Path | None = None
+
+ def _path_for_date(self, d: date) -> Path:
+ weekday = d.strftime("%A").lower()
+ return self.log_dir / f"backend-{weekday}-{d.isoformat()}.log"
+
+ def _rotate_if_needed(self, now: datetime) -> None:
+ today = now.date()
+ if self._active_date == today and self._stream is not None:
+ return
+
+ self._close_stream()
+ self.log_dir.mkdir(parents=True, exist_ok=True)
+ self._active_path = self._path_for_date(today)
+ self._stream = self._active_path.open("a", encoding=self.encoding)
+ self._active_date = today
+
+ # Run cleanup after opening the current day's stream.
+ cleanup_backend_log_files(
+ log_dir=self.log_dir,
+ retention_days=self.retention_days,
+ now=now,
+ )
+
+ def _close_stream(self) -> None:
+ if self._stream is None:
+ return
+ try:
+ self._stream.flush()
+ self._stream.close()
+ finally:
+ self._stream = None
+
+ def emit(self, record: logging.LogRecord) -> None:
+ try:
+ now = datetime.now()
+ self._rotate_if_needed(now)
+ if self._stream is None:
+ return
+ msg = self.format(record)
+ self._stream.write(msg + self.terminator)
+ self._stream.flush()
+ except Exception:
+ self.handleError(record)
+
+ def close(self) -> None:
+ try:
+ self._close_stream()
+ finally:
+ super().close()
+
+
+def _resolve_log_level(level: int | None = None) -> int:
+ if level is not None:
+ return level
+ raw = (
+ (os.getenv("BACKEND_LOG_LEVEL") or os.getenv("LOG_LEVEL") or "INFO")
+ .strip()
+ .upper()
+ )
+ return getattr(logging, raw, logging.INFO)
+
+
+def _resolve_log_dir(log_dir: str | Path | None = None) -> Path:
+ if log_dir is not None:
+ return Path(log_dir)
+ raw = os.getenv("BACKEND_LOG_DIR")
+ if raw:
+ return Path(raw)
+ return Path(STATE_DIR) / "logs"
+
+
+def _resolve_retention_days(retention_days: int | None = None) -> int:
+ if retention_days is not None:
+ return max(0, retention_days)
+ raw = os.getenv("BACKEND_LOG_RETENTION_DAYS", "7").strip()
+ try:
+ return max(0, int(raw))
+ except ValueError:
+ return 7
+
+
+def cleanup_backend_log_files(
+ *,
+ log_dir: str | Path,
+ retention_days: int,
+ now: datetime | None = None,
+) -> list[Path]:
+ """Delete backend log files older than retention_days.
+
+ Returns list of removed file paths.
+ """
+ current = now or datetime.now()
+ keep_days = max(1, retention_days)
+ cutoff_date = current.date() - timedelta(days=keep_days - 1)
+ base = Path(log_dir)
+ if not base.exists():
+ return []
+
+ removed: list[Path] = []
+ for path in base.glob("backend-*.log"):
+ match = _BACKEND_FILE_RE.match(path.name)
+ if not match:
+ continue
+ file_date_raw = match.group(2)
+ try:
+ file_date = date.fromisoformat(file_date_raw)
+ except ValueError:
+ continue
+ if file_date < cutoff_date:
+ try:
+ path.unlink()
+ removed.append(path)
+ except OSError:
+ # Non-fatal; logging setup should never crash app startup.
+ continue
+ return removed
+
+
+def configure_backend_logging(
+ *,
+ level: int | None = None,
+ log_dir: str | Path | None = None,
+ retention_days: int | None = None,
+) -> int:
+ """Configure root logger with stdout + daily backend file logging.
+
+ Safe to call multiple times: it removes/replaces previously installed
+ backend handlers to avoid duplicates.
+
+ Returns the resolved integer log level used for backend logging.
+ """
+ resolved_level = _resolve_log_level(level)
+ resolved_log_dir = _resolve_log_dir(log_dir)
+ resolved_retention = _resolve_retention_days(retention_days)
+
+ root = logging.getLogger()
+ root.setLevel(resolved_level)
+
+ # Remove previous backend-managed handlers to avoid duplicates.
+ for handler in list(root.handlers):
+ if getattr(handler, "_code_puppy_backend_logging", False):
+ root.removeHandler(handler)
+ handler.close()
+
+ formatter = logging.Formatter(_DEFAULT_FORMAT, datefmt=_DEFAULT_DATEFMT)
+
+ stream_handler = logging.StreamHandler(sys.stdout)
+ stream_handler.setLevel(resolved_level)
+ stream_handler.setFormatter(formatter)
+ setattr(stream_handler, "_code_puppy_backend_logging", True)
+ setattr(stream_handler, "_code_puppy_backend_logging_kind", "stream")
+ root.addHandler(stream_handler)
+
+ try:
+ resolved_log_dir.mkdir(parents=True, exist_ok=True)
+
+ file_handler = DailyBackendFileHandler(
+ log_dir=resolved_log_dir,
+ retention_days=resolved_retention,
+ )
+ file_handler.setLevel(resolved_level)
+ file_handler.setFormatter(formatter)
+ setattr(file_handler, "_code_puppy_backend_logging", True)
+ setattr(file_handler, "_code_puppy_backend_logging_kind", "file")
+ root.addHandler(file_handler)
+
+ # Also run cleanup at configuration time.
+ cleanup_backend_log_files(
+ log_dir=resolved_log_dir,
+ retention_days=resolved_retention,
+ )
+ except Exception as exc:
+ print(
+ f"[code_puppy.logging_setup] backend file logging disabled: {exc}",
+ file=sys.stderr,
+ )
+ root.warning(
+ "Backend file logging disabled; continuing with stdout logging only.",
+ exc_info=True,
+ )
+
+ return resolved_level
diff --git a/code_puppy/model_errors.py b/code_puppy/model_errors.py
new file mode 100644
index 000000000..87ea86690
--- /dev/null
+++ b/code_puppy/model_errors.py
@@ -0,0 +1,277 @@
+"""Model error normalization utilities.
+
+Central place to parse provider-specific model errors (Anthropic, OpenAI,
+Gemini, etc.) into a small, stable structure that the rest of the
+application can use for retries and user-facing error messages.
+
+This keeps provider-specific logic out of the core agent code.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any, Optional
+
+
+@dataclass
+class NormalizedModelError:
+ """Provider-agnostic view of a model error."""
+
+ provider: Optional[str]
+ code: Optional[str]
+ http_status: Optional[int]
+ is_transient: bool
+ user_message: str
+ raw_message: str
+
+
+def _get_http_status(exc: Exception) -> Optional[int]:
+ """Best-effort extraction of an HTTP status code from an exception."""
+
+ for attr in ("status_code", "status", "http_status", "code"):
+ value = getattr(exc, attr, None)
+ if isinstance(value, int) and 100 <= value <= 599:
+ return value
+ return None
+
+
+def _get_body_dict(exc: Exception) -> Optional[dict[str, Any]]:
+ """Try to extract a structured body dict from provider exceptions."""
+
+ for attr in ("body", "response", "error", "errors", "detail"):
+ value = getattr(exc, attr, None)
+ if isinstance(value, dict):
+ return value
+ return None
+
+
+def _detect_provider(exc: Exception) -> Optional[str]:
+ """Infer model provider from the exception's module / type name."""
+
+ module = getattr(exc.__class__, "__module__", "")
+ name = exc.__class__.__name__.lower()
+ module_lower = module.lower()
+
+ if "anthropic" in module_lower or "anthropic" in name:
+ return "anthropic"
+ if "openai" in module_lower or "openai" in name:
+ return "openai"
+ if "google" in module_lower or "gemini" in module_lower or "gemini" in name:
+ return "gemini"
+ if "azure" in module_lower or "azure" in name:
+ return "azure-openai"
+
+ return None
+
+
+def _classify_message(
+ provider: Optional[str],
+ raw: str,
+ payload: Optional[dict[str, Any]],
+ http_status: Optional[int],
+) -> tuple[Optional[str], bool]:
+ """Map provider error details to a (code, is_transient) pair.
+
+ The goal is to keep this classification small and robust, not to
+ perfectly mirror each provider's error taxonomy.
+ """
+
+ lower = raw.lower()
+
+ # Rate limiting / overloaded / transient backend issues
+ if any(
+ k in lower
+ for k in ("rate limit", "too many requests", "overloaded", "try again later")
+ ):
+ return "rate_limit_or_overloaded", True
+
+ if any(
+ k in lower
+ for k in (
+ "timeout",
+ "temporarily unavailable",
+ "server error",
+ "gateway",
+ "bad gateway",
+ )
+ ):
+ return "backend_unavailable", True
+
+ # HTTP status based classification (when available)
+ if http_status in (429, 509):
+ return "rate_limit_or_overloaded", True
+ if http_status in (500, 502, 503, 504):
+ return "backend_unavailable", True
+
+ # Auth / configuration
+ if any(
+ k in lower
+ for k in (
+ "invalid api key",
+ "authentication",
+ "unauthorized",
+ "forbidden",
+ "permission",
+ )
+ ):
+ return "auth_error", False
+
+ # Model / quota
+ if any(
+ k in lower
+ for k in (
+ "model_not_found",
+ "unknown model",
+ "unsupported model",
+ "no such model",
+ )
+ ):
+ return "unsupported_model", False
+
+ if any(
+ k in lower for k in ("quota", "billing", "insufficient balance", "subscription")
+ ):
+ return "quota_exceeded", False
+
+ # Safety / content policy
+ if any(
+ k in lower
+ for k in ("safety", "content policy", "blocked content", "policy violation")
+ ):
+ return "content_blocked", False
+
+ # Tool history corruption (Anthropic 400: orphaned or duplicated tool blocks).
+ # These are non-transient — the history must be pruned before retrying.
+ # Two distinct error shapes are handled:
+ # 1. Orphaned tool_result: "unexpected tool_use_id" / "each tool_result block
+ # must have a corresponding tool_use" — a tool_result with no matching tool_use.
+ # 2. Duplicate tool_result: "each tool_use must have a single result. Found
+ # multiple tool_result blocks with id: ..." — happens when a streaming session
+ # is interrupted mid-tool-call and the user resumes with "continue".
+ if any(
+ k in lower
+ for k in (
+ "unexpected tool_use_id",
+ "unexpected tool_result",
+ "tool_use ids found without tool_result",
+ "each tool_result block must have a corresponding tool_use",
+ "each tool_use must have a single result",
+ "found multiple `tool_result` blocks",
+ )
+ ):
+ return "invalid_tool_history", False
+
+ # Default: unknown and non-transient
+ return None, False
+
+
+def _build_user_message(
+ provider: Optional[str],
+ code: Optional[str],
+ is_transient: bool,
+ raw: str,
+) -> str:
+ """Return a short, user-facing message based on normalized fields."""
+
+ prov = provider or "The model"
+
+ if code == "rate_limit_or_overloaded":
+ return (
+ f"{prov} is temporarily overloaded or rate-limited. "
+ "You can try again in a bit, or switch models with /model."
+ )
+
+ if code == "backend_unavailable":
+ return (
+ f"{prov} is currently unavailable due to a server issue. "
+ "Please try again shortly or switch models with /model."
+ )
+
+ if code == "auth_error":
+ return (
+ f"{prov} rejected this request due to an authentication or "
+ "permission error. Check your API key and configuration."
+ )
+
+ if code == "unsupported_model":
+ return (
+ f"The requested model is not available for {prov}. "
+ "Try switching to a different model with /model."
+ )
+
+ if code == "quota_exceeded":
+ return (
+ f"{prov} reports that your quota or billing limits have been "
+ "exceeded. Check your usage or choose a different provider/model."
+ )
+
+ if code == "content_blocked":
+ return (
+ f"{prov} blocked this request due to safety or content policy "
+ "restrictions. Try rephrasing the request."
+ )
+
+ if code == "invalid_tool_history":
+ return (
+ "The conversation history has mismatched tool calls. "
+ "Code Puppy is attempting to repair it automatically. "
+ "If this keeps happening, starting a new session will resolve it."
+ )
+
+ # Fallback: generic but informative
+ short_raw = raw.strip().split("\n", 1)[0]
+ if len(short_raw) > 200:
+ short_raw = short_raw[:197] + "..."
+
+ return f"The model returned an unexpected error: {short_raw}"
+
+
+def normalize_model_error(exc: Exception) -> NormalizedModelError:
+ """Normalize provider-specific model errors into a common structure.
+
+ This is intentionally conservative: when we cannot confidently
+ classify an error, we mark it as non-transient and provide a
+ generic user-facing message.
+
+ Classification uses the combined text from:
+ - ``str(exc)`` — the primary error representation.
+ - String-valued ``.message``, ``.body``, and ``.detail`` attributes —
+ Anthropic's ``APIStatusError`` surfaces the HTTP body as ``.message``.
+ - The exception's ``__cause__`` chain.
+ """
+
+ raw = str(exc) if exc is not None else ""
+
+ # Collect additional candidate text from well-known string attributes so
+ # that providers that embed the error body in a non-str attribute (e.g.
+ # Anthropic's APIStatusError.message) are also classified correctly.
+ parts: list[str] = [raw]
+ for attr in ("message", "body", "detail"):
+ val = getattr(exc, attr, None)
+ if val and isinstance(val, str) and val != raw:
+ parts.append(val)
+ elif val and not isinstance(val, str):
+ try:
+ parts.append(str(val))
+ except Exception: # noqa: BLE001
+ pass
+ cause = getattr(exc, "__cause__", None)
+ if cause is not None:
+ parts.append(str(cause))
+ combined_raw = " ".join(parts)
+
+ provider = _detect_provider(exc)
+ http_status = _get_http_status(exc)
+ payload = _get_body_dict(exc)
+
+ code, is_transient = _classify_message(provider, combined_raw, payload, http_status)
+ user_message = _build_user_message(provider, code, is_transient, combined_raw)
+
+ return NormalizedModelError(
+ provider=provider,
+ code=code,
+ http_status=http_status,
+ is_transient=is_transient,
+ user_message=user_message,
+ raw_message=raw,
+ )
From 52cd3b1ea581f24770eaaa78ebf55812a0cbd11b Mon Sep 17 00:00:00 2001
From: shravya maddipudi
Date: Mon, 1 Jun 2026 13:56:44 -0500
Subject: [PATCH 08/29] chore(migration): add legacy ws snapshot and migration
docs
---
.gitignore | 2 +
code_puppy/api/ws/legacy/README.md | 11 +
code_puppy/api/ws/legacy/__init__.py | 12 +
code_puppy/api/ws/legacy/attachments.py | 93 +
code_puppy/api/ws/legacy/background_save.py | 244 ++
code_puppy/api/ws/legacy/chat_handler.py | 3898 +++++++++++++++++
.../api/ws/legacy/connection_manager.py | 55 +
code_puppy/api/ws/legacy/events_handler.py | 53 +
code_puppy/api/ws/legacy/health_handler.py | 22 +
code_puppy/api/ws/legacy/runtime/__init__.py | 14 +
.../api/ws/legacy/runtime/session_runtime.py | 68 +
.../legacy/runtime/session_runtime_manager.py | 150 +
code_puppy/api/ws/legacy/schemas.py | 603 +++
code_puppy/api/ws/legacy/sessions_handler.py | 69 +
.../puppy-desk-gate1-investigation-summary.md | 426 ++
.../puppy-desk-migration-plan-options.md | 825 ++++
.../puppy-desk-uncommitted-change-snapshot.md | 1567 +++++++
17 files changed, 8112 insertions(+)
create mode 100644 code_puppy/api/ws/legacy/README.md
create mode 100644 code_puppy/api/ws/legacy/__init__.py
create mode 100644 code_puppy/api/ws/legacy/attachments.py
create mode 100644 code_puppy/api/ws/legacy/background_save.py
create mode 100644 code_puppy/api/ws/legacy/chat_handler.py
create mode 100644 code_puppy/api/ws/legacy/connection_manager.py
create mode 100644 code_puppy/api/ws/legacy/events_handler.py
create mode 100644 code_puppy/api/ws/legacy/health_handler.py
create mode 100644 code_puppy/api/ws/legacy/runtime/__init__.py
create mode 100644 code_puppy/api/ws/legacy/runtime/session_runtime.py
create mode 100644 code_puppy/api/ws/legacy/runtime/session_runtime_manager.py
create mode 100644 code_puppy/api/ws/legacy/schemas.py
create mode 100644 code_puppy/api/ws/legacy/sessions_handler.py
create mode 100644 docs/migration/puppy-desk-gate1-investigation-summary.md
create mode 100644 docs/migration/puppy-desk-migration-plan-options.md
create mode 100644 docs/migration/puppy-desk-uncommitted-change-snapshot.md
diff --git a/.gitignore b/.gitignore
index 56e9f3c35..603b7981f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -45,3 +45,5 @@ code_puppy/bundled_skills/
.claude/hooks/ts-hooks/dist/
.json
+
+.maestro/*
diff --git a/code_puppy/api/ws/legacy/README.md b/code_puppy/api/ws/legacy/README.md
new file mode 100644
index 000000000..113e3a41f
--- /dev/null
+++ b/code_puppy/api/ws/legacy/README.md
@@ -0,0 +1,11 @@
+# Legacy WS Snapshot — Puppy Desk Gate 2
+
+This directory is a reference/rollback copy of `code_puppy.api.ws` captured
+before further puppy-desk migration/refactor work.
+
+Rules:
+
+- It is not registered by `setup_websocket()`.
+- Existing `/ws/chat` behavior remains provided by `code_puppy.api.ws.chat_handler`.
+- Same-route swap must not happen until parity and GUGI validation pass.
+- Keep this snapshot until user explicitly approves cleanup.
diff --git a/code_puppy/api/ws/legacy/__init__.py b/code_puppy/api/ws/legacy/__init__.py
new file mode 100644
index 000000000..fae8b03db
--- /dev/null
+++ b/code_puppy/api/ws/legacy/__init__.py
@@ -0,0 +1,12 @@
+"""Legacy WebSocket implementation snapshot for puppy-desk migration.
+
+This package is a reference/rollback copy captured during Gate 2 of the
+puppy-desk migration. It is intentionally not imported or registered by the
+runtime application yet; the active routes continue to come from
+``code_puppy.api.ws``.
+
+Do not edit this snapshot as part of feature work unless intentionally updating
+the legacy rollback point.
+"""
+
+LEGACY_SNAPSHOT_PURPOSE = "puppy-desk-gate2-reference-copy"
diff --git a/code_puppy/api/ws/legacy/attachments.py b/code_puppy/api/ws/legacy/attachments.py
new file mode 100644
index 000000000..f2db90726
--- /dev/null
+++ b/code_puppy/api/ws/legacy/attachments.py
@@ -0,0 +1,93 @@
+"""File attachment processing utilities for WebSocket chat.
+
+Handles converting file attachment paths into either binary content
+(for images/PDFs) or text context (for code/text files).
+"""
+
+import logging
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+# Supported binary types (Claude's native attachments)
+BINARY_EXTENSIONS = {
+ ".png",
+ ".jpg",
+ ".jpeg",
+ ".gif",
+ ".webp",
+ ".pdf",
+ ".bmp",
+ ".tiff",
+}
+
+
+def build_file_context_and_attachments(msg: dict):
+ """Convert attachment paths into either binary attachments OR text context.
+
+ For images/PDFs: Send as BinaryContent (Claude's native attachment support)
+ For text files: Read and prepend to message so Code Puppy can analyze them
+
+ Returns: (text_context, binary_attachments)
+ text_context: String to prepend to the message, or empty string
+ binary_attachments: List of BinaryContent for images/PDFs
+ """
+ attachment_paths = msg.get("attachments") or []
+
+ if not attachment_paths:
+ return "", []
+
+ text_context_parts = []
+ binary_attachments = []
+
+ for raw_path in attachment_paths:
+ if not isinstance(raw_path, str) or not raw_path.strip():
+ continue
+
+ try:
+ file_path = Path(raw_path)
+
+ if not file_path.exists():
+ logger.warning("Attachment file not found: %s", raw_path)
+ continue
+
+ ext = file_path.suffix.lower()
+
+ if ext in BINARY_EXTENSIONS:
+ try:
+ from pydantic_ai import BinaryContent
+
+ from code_puppy.command_line.attachments import (
+ _determine_media_type,
+ _load_binary,
+ )
+
+ data = _load_binary(file_path)
+ media_type = _determine_media_type(file_path)
+ binary_attachments.append(
+ BinaryContent(data=data, media_type=media_type)
+ )
+ logger.debug("Loaded binary attachment: %s", file_path.name)
+ except Exception as e:
+ logger.warning(
+ f"Failed to load binary attachment '{raw_path}': {e}"
+ )
+ else:
+ try:
+ content = file_path.read_text(encoding="utf-8", errors="ignore")
+ text_context_parts.append(
+ f"\n\n--- File: {file_path.name} ({raw_path}) ---\n"
+ f"{content}\n"
+ f"--- End of {file_path.name} ---\n"
+ )
+ logger.debug(
+ f"Loaded text file: {file_path.name} ({len(content)} chars)"
+ )
+ except Exception as e:
+ logger.warning("Failed to read text file '%s': %s", raw_path, e)
+
+ except Exception as e:
+ logger.warning("Error processing attachment '%s': %s", raw_path, e)
+
+ text_context = "".join(text_context_parts)
+ return text_context, binary_attachments
diff --git a/code_puppy/api/ws/legacy/background_save.py b/code_puppy/api/ws/legacy/background_save.py
new file mode 100644
index 000000000..4b1a1c6ad
--- /dev/null
+++ b/code_puppy/api/ws/legacy/background_save.py
@@ -0,0 +1,244 @@
+"""Background agent result persistence for WebSocket chat sessions.
+
+When a user switches sessions or disconnects while the agent is running, the
+agent is allowed to finish in the background. This module provides a single
+reusable coroutine — ``save_agent_result_in_background`` — that:
+
+1. Awaits the agent task.
+2. Syncs completed messages onto the agent instance.
+3. Checks the session still exists (guards against resurrecting deleted sessions).
+4. Persists the result to SQLite via ``write_turn_to_sqlite``.
+
+All three original call-sites (switch, disconnect, runtime-error) are
+collapsed here, eliminating ~363 lines of duplicated closure code.
+
+Usage
+-----
+::
+
+ from code_puppy.api.ws.background_save import (
+ fire_and_track,
+ save_agent_result_in_background,
+ )
+
+ fire_and_track(
+ save_agent_result_in_background(
+ agent_task=active_agent_task,
+ session_id=session_id,
+ ctx=ctx,
+ agent=agent,
+ agent_name=agent_name,
+ model_name=model_name,
+ title=session_title,
+ working_directory=session_working_directory,
+ pinned=session_pinned,
+ label="switch", # "switch" | "disconnect" | "runtime"
+ )
+ )
+"""
+
+from __future__ import annotations
+
+import asyncio
+import datetime
+import logging
+from typing import Any
+
+from code_puppy.api.db.queries import session_exists, write_turn_to_sqlite
+
+logger = logging.getLogger(__name__)
+
+# ---------------------------------------------------------------------------
+# Task registry — prevents fire-and-forget tasks from being GC'd mid-flight.
+# ---------------------------------------------------------------------------
+
+_BACKGROUND_TASKS: set[asyncio.Task] = set()
+
+
+def fire_and_track(coro: Any) -> asyncio.Task:
+ """Spawn *coro* as a tracked background Task.
+
+ The task is kept alive in ``_BACKGROUND_TASKS`` until it completes,
+ preventing it from being garbage-collected before it finishes.
+ """
+ task = asyncio.create_task(coro)
+ _BACKGROUND_TASKS.add(task)
+ task.add_done_callback(_BACKGROUND_TASKS.discard)
+ return task
+
+
+# ---------------------------------------------------------------------------
+# Core background-save coroutine
+# ---------------------------------------------------------------------------
+
+
+async def save_agent_result_in_background(
+ *,
+ agent_task: asyncio.Task | None,
+ session_id: str,
+ ctx: Any, # SessionContext | None
+ agent: Any, # code_puppy Agent instance
+ agent_name: str,
+ model_name: str,
+ title: str,
+ working_directory: str,
+ pinned: bool,
+ label: str = "bg",
+) -> None:
+ """Await *agent_task*, then persist the result to SQLite.
+
+ Parameters
+ ----------
+ agent_task:
+ The running ``run_with_mcp`` asyncio Task. ``None`` is a safe no-op.
+ session_id:
+ The session whose messages should be persisted.
+ ctx:
+ The ``SessionContext`` snapshot at time of the switch/disconnect.
+ May be ``None``; used for ``created_at`` and token estimation.
+ agent:
+ Agent instance attached to the session.
+ agent_name / model_name / title / working_directory / pinned:
+ Session metadata — snapshotted by the caller before state changes.
+ label:
+ Short string used in log messages: ``"switch"``, ``"disconnect"``,
+ or ``"runtime"``.
+ """
+ if agent_task is None:
+ return
+
+ try:
+ # ------------------------------------------------------------------
+ # 1. Await the in-flight agent task.
+ # ------------------------------------------------------------------
+ try:
+ result = await agent_task
+ except asyncio.CancelledError:
+ logger.debug("[BG:%s] Agent task was cancelled (%s)", session_id, label)
+ return
+ except Exception as exc:
+ logger.warning("[BG:%s] Agent task failed (%s): %s", session_id, label, exc)
+ return
+
+ if result is None:
+ return
+
+ # ------------------------------------------------------------------
+ # 2. Sync completed messages back onto the agent instance.
+ # ------------------------------------------------------------------
+ try:
+ all_msgs = result.all_messages()
+ agent.set_message_history(all_msgs)
+ except Exception as exc:
+ logger.warning(
+ "[BG:%s] Could not extract messages from result (%s): %s",
+ session_id,
+ label,
+ exc,
+ )
+ # Don't bail — get_message_history() may still hold a partial history.
+
+ history = agent.get_message_history()
+ if not history:
+ logger.debug(
+ "[BG:%s] Empty history after completion (%s) — nothing to save",
+ session_id,
+ label,
+ )
+ return
+
+ # ------------------------------------------------------------------
+ # 3. Guard: skip write if the session was deleted between task start
+ # and completion (avoids resurrecting a deliberately deleted session).
+ # ------------------------------------------------------------------
+ try:
+ if not await session_exists(session_id):
+ logger.info(
+ "[BG:%s] Session deleted before background save (%s) — skipping",
+ session_id,
+ label,
+ )
+ return
+ except Exception as exc:
+ logger.warning(
+ "[BG:%s] session_exists check failed (%s): %s — proceeding anyway",
+ session_id,
+ label,
+ exc,
+ )
+
+ # ------------------------------------------------------------------
+ # 4. Build enhanced history wrappers.
+ # Pre-wrapped dicts (legacy or in-memory system-message injections)
+ # pass through unchanged; bare ModelMessage objects get wrapped.
+ # ------------------------------------------------------------------
+ now_iso = datetime.datetime.now(datetime.timezone.utc).isoformat()
+
+ enhanced_history: list[dict[str, Any]] = []
+ for msg in history:
+ if isinstance(msg, dict) and "msg" in msg:
+ enhanced_history.append(msg)
+ else:
+ enhanced_history.append(
+ {
+ "msg": msg,
+ "agent": agent_name,
+ "model": model_name,
+ "ts": now_iso,
+ }
+ )
+
+ # ------------------------------------------------------------------
+ # 5. Compute token count from the completed history.
+ # The three old closures hard-coded 0; we compute it properly here.
+ # ------------------------------------------------------------------
+ total_tokens = 0
+ try:
+ for item in enhanced_history:
+ msg_obj = (
+ item["msg"] if isinstance(item, dict) and "msg" in item else item
+ )
+ total_tokens += agent.estimate_tokens_for_message(msg_obj)
+ except Exception:
+ total_tokens = 0
+
+ # ------------------------------------------------------------------
+ # 6. Resolve created_at safely — ctx may lack the attribute in tests.
+ # ------------------------------------------------------------------
+ ctx_created_at = getattr(ctx, "created_at", None)
+ created_at = (
+ ctx_created_at.isoformat() if ctx_created_at is not None else now_iso
+ )
+
+ # ------------------------------------------------------------------
+ # 7. Persist to SQLite.
+ # ------------------------------------------------------------------
+ await write_turn_to_sqlite(
+ session_id=session_id,
+ enhanced_history=enhanced_history,
+ title=title,
+ working_directory=working_directory,
+ pinned=pinned,
+ agent_name=agent_name,
+ model_name=model_name,
+ total_tokens=total_tokens,
+ updated_at=now_iso,
+ created_at=created_at,
+ ctx=ctx,
+ )
+ logger.info(
+ "[BG:%s] Background save complete (%s): %d messages, %d tokens",
+ session_id,
+ label,
+ len(enhanced_history),
+ total_tokens,
+ )
+
+ except Exception as exc:
+ logger.error(
+ "[BG:%s] Background save failed (%s): %s",
+ session_id,
+ label,
+ exc,
+ exc_info=True,
+ )
diff --git a/code_puppy/api/ws/legacy/chat_handler.py b/code_puppy/api/ws/legacy/chat_handler.py
new file mode 100644
index 000000000..fbcb4c918
--- /dev/null
+++ b/code_puppy/api/ws/legacy/chat_handler.py
@@ -0,0 +1,3898 @@
+"""WebSocket endpoint for interactive chat with the Code Puppy agent.
+
+This is the largest and most complex WebSocket handler, responsible for:
+- Interactive chat sessions with streaming responses
+- Session management (create, restore, switch)
+- Tool call/result forwarding
+- File attachment processing
+- Slash command handling
+- Permission request/response flow
+- Working directory management
+- Real-time event streaming from the agent
+
+NOTE: This handler was extracted from the monolithic websocket.py to improve
+maintainability. The internal structure is preserved to avoid regressions.
+Future refactoring should break down the message handling loop further.
+"""
+
+import asyncio
+import datetime
+import json
+import logging
+import re
+import uuid
+from pathlib import Path
+from typing import Any
+
+from fastapi import FastAPI, WebSocket, WebSocketDisconnect
+from pydantic import TypeAdapter, ValidationError
+
+from code_puppy.api.db.queries import (
+ update_session_working_directory,
+ write_error_message_to_sqlite,
+ write_system_message_to_sqlite,
+ write_turn_to_sqlite,
+)
+from code_puppy.api.error_parser import parse_api_error as _legacy_parse_api_error
+from code_puppy.api.session_context import _validate_session_id, session_manager
+from code_puppy.api.ws.attachments import build_file_context_and_attachments
+from code_puppy.api.ws.background_save import (
+ fire_and_track,
+ save_agent_result_in_background,
+)
+from code_puppy.api.ws.connection_manager import connection_manager
+from code_puppy.api.ws.schemas import (
+ PROTOCOL_VERSION,
+ ClientMessage,
+ ServerAgentInvoked,
+ ServerAssistantMessageDelta,
+ ServerAssistantMessageEnd,
+ ServerAssistantMessageStart,
+ ServerCancelled,
+ ServerCommandResult,
+ ServerConfigValue,
+ ServerError,
+ ServerMessage,
+ ServerResponse,
+ ServerSessionMetaUpdated,
+ ServerSessionRestored,
+ ServerSessionSwitched,
+ ServerStatus,
+ ServerStreamEnd,
+ ServerSystem,
+ ServerToolCall,
+ ServerToolResult,
+ ServerUserMessage,
+ ServerWorkingDirectoryChanged,
+)
+from code_puppy.config import get_global_model_name
+from code_puppy.messaging.bus import get_message_bus
+from code_puppy.model_errors import normalize_model_error as _normalize_model_error
+from code_puppy.session_storage import generate_heuristic_title
+from code_puppy.tools.command_runner import (
+ cleanup_session_process_tracking,
+ init_session_process_tracking,
+)
+
+# Import session context for desk-puppy working directory support
+try:
+ from code_puppy.plugins.walmart_specific.session_context import (
+ clear_session_working_directory,
+ set_session_working_directory,
+ )
+
+ HAS_SESSION_CONTEXT = True
+except ImportError:
+ HAS_SESSION_CONTEXT = False
+
+ def set_session_working_directory(d):
+ pass
+
+ def clear_session_working_directory():
+ pass
+
+
+_ClientMessageAdapter = TypeAdapter(ClientMessage)
+
+logger = logging.getLogger(__name__)
+
+# === TOOL-CALL-PARITY BRANCH LOADED ===
+logger.warning(
+ "🐕 CHAT_HANDLER LOADED FROM TOOL-CALL-PARITY BRANCH - ToolReturnPart fix active!"
+)
+print("🐕 CHAT_HANDLER LOADED FROM TOOL-CALL-PARITY BRANCH", flush=True)
+
+
+# ---------------------------------------------------------------------------
+# WebSocket error frame helpers (see fix/orphaned-tool-use-id-history)
+# ---------------------------------------------------------------------------
+
+_UNKNOWN_ERROR_TYPE = "unknown_error"
+_TOOL_HISTORY_ERROR_TYPE = "tool_history_error"
+
+_CODE_TO_ERROR_TYPE: dict = {
+ "rate_limit_or_overloaded": "rate_limit",
+ "backend_unavailable": "server_error",
+ "auth_error": "auth_error",
+ "quota_exceeded": "quota_exceeded",
+ "content_blocked": "content_blocked",
+ "invalid_tool_history": _TOOL_HISTORY_ERROR_TYPE,
+}
+
+
+def parse_api_error(exc: Exception) -> dict:
+ """Convert an agent-run exception into a structured frontend error dict.
+
+ Wraps ``normalize_model_error`` for primary classification (including the
+ new ``invalid_tool_history`` code for Anthropic HTTP 400 tool-mismatch
+ errors), falling back to the legacy ``_legacy_parse_api_error`` for all
+ other categories so existing behaviour is unchanged.
+
+ Returns:
+ Dict with keys: user_message, error_type, technical_details, action_required.
+ """
+ norm = _normalize_model_error(exc)
+ if norm.code in _CODE_TO_ERROR_TYPE:
+ error_type = _CODE_TO_ERROR_TYPE[norm.code]
+ user_message = norm.user_message or str(exc)
+ action_required = error_type in ("rate_limit", "quota_exceeded")
+ return {
+ "user_message": user_message,
+ "error_type": error_type,
+ "technical_details": repr(exc),
+ "action_required": action_required,
+ }
+ # Fall back to legacy parser for unclassified errors
+ return _legacy_parse_api_error(exc)
+
+
+def has_streamed_content(collected_text) -> bool:
+ """Return True when the client has already received streaming output chunks.
+
+ Args:
+ collected_text: List of text chunks accumulated during the agent run.
+ Elements may be None or empty strings.
+
+ Returns:
+ True if at least one chunk has substantive (non-whitespace) content.
+ """
+ return any((chunk or "").strip() for chunk in collected_text)
+
+
+def build_error_response_frames(
+ agent_error: Exception,
+ collected_text,
+ session_id: str,
+) -> list:
+ """Build the ordered WebSocket frames to send when an agent error occurs.
+
+ When streaming output was already delivered to the client, a ``stream_end``
+ frame (``success=False``) is prepended so the frontend can exit its
+ streaming state before processing the error frame. Without this handshake
+ the frontend hangs indefinitely waiting for a ``stream_end`` that never
+ arrives.
+
+ Args:
+ agent_error: The exception raised by the agent run.
+ collected_text: Accumulated streaming chunks from the current turn.
+ session_id: The WebSocket session identifier.
+
+ Returns:
+ List of JSON-serialisable dicts to send to the client in order.
+ """
+ frames: list[dict] = []
+ if has_streamed_content(collected_text):
+ frames.append(
+ ServerStreamEnd(
+ success=False,
+ session_id=session_id,
+ ).model_dump(exclude_none=True)
+ )
+ parsed = parse_api_error(agent_error)
+ frames.append(
+ ServerError(
+ error=parsed["user_message"],
+ error_type=parsed["error_type"],
+ technical_details=parsed["technical_details"],
+ action_required=parsed.get("action_required"),
+ session_id=session_id,
+ ).model_dump(exclude_none=True)
+ )
+ return frames
+
+
+def register_chat_endpoint(app: FastAPI) -> None:
+ """Register the /ws/chat WebSocket endpoint."""
+
+ @app.websocket("/ws/chat")
+ async def websocket_chat(
+ websocket: WebSocket, session_id: str | None = None
+ ) -> None:
+ """Interactive chat with the Code Puppy agent.
+
+ Protocol:
+ Client sends:
+ {"type": "message", "content": "your message here"}
+
+ Server sends:
+ # Streaming message events (all include agent_name, model_name, tool_name metadata)
+ {"type": "assistant_message_start", "message_id": "...", "part_type": "text|thinking",
+ "agent_name": "...", "model_name": "...", "tool_name": "..." or null}
+ {"type": "assistant_message_delta", "message_id": "...", "content": "...",
+ "agent_name": "...", "model_name": "...", "tool_name": "..." or null}
+ {"type": "assistant_message_end", "message_id": "...", "full_content": "...",
+ "agent_name": "...", "model_name": "...", "tool_name": "..." or null}
+
+ # Tool call events (include agent_name, model_name metadata)
+ {"type": "tool_call", "tool_name": "...", "args": {...},
+ "agent_name": "...", "model_name": "..."}
+ {"type": "tool_result", "tool_name": "...", "result": "...", "success": true,
+ "agent_name": "...", "model_name": "..."}
+
+ # Final response
+ {"type": "response", "content": "...", "done": true,
+ "agent_name": "...", "model_name": "...", "tokens": {...}}
+
+ # Errors
+ {"type": "error", "error": "..."}
+
+ Query Parameters:
+ session_id: Optional session ID to resume. If not provided, a new session is created.
+ Example: /ws/chat?session_id=WS_session_20260115_143022
+ """
+ await websocket.accept()
+ logger.debug(
+ "Chat WebSocket client connected (session_id param: %s)", session_id
+ )
+
+ # Flag to track if WebSocket is still open
+ ws_closed = False
+
+ async def persist_error_payload(data: dict[str, Any]) -> None:
+ """Persist structured error frames so they survive reloads."""
+ if data.get("type") != "error" or not session_id:
+ return
+
+ try:
+ await write_error_message_to_sqlite(
+ session_id=session_id,
+ error=str(data.get("error") or "An unknown error occurred"),
+ error_type=str(data.get("error_type") or "unknown"),
+ technical_details=str(data.get("technical_details") or ""),
+ action_required=data.get("action_required"),
+ agent_name=(ctx.agent_name if ctx else ""),
+ model_name=(ctx.model_name if ctx else ""),
+ timestamp=datetime.datetime.now(datetime.timezone.utc).isoformat(),
+ )
+ except Exception:
+ logger.warning(
+ "[WS:%s] Failed to persist error payload to SQLite",
+ session_id,
+ exc_info=True,
+ )
+
+ async def safe_send_json(data: dict) -> bool:
+ """Safely send JSON to WebSocket, returns False if connection is closed."""
+ nonlocal ws_closed
+ if ws_closed:
+ logger.debug(
+ "[WS:%s] safe_send_json skipped (ws_closed=True): type=%s",
+ session_id,
+ data.get("type"),
+ )
+ return False
+
+ msg_type = data.get("type")
+ if msg_type in {
+ "error",
+ "response",
+ "assistant_message_start",
+ "assistant_message_end",
+ }:
+ # Keep this low-noise but useful for tracing request lifecycle
+ logger.debug(
+ "[WS:%s] → send_json type=%s keys=%s",
+ session_id,
+ msg_type,
+ sorted(list(data.keys())),
+ )
+ if msg_type == "error":
+ logger.debug(
+ "[WS:%s] error payload: error_type=%r action_required=%r error=%r",
+ session_id,
+ data.get("error_type"),
+ data.get("action_required"),
+ (data.get("error") or "")[:300],
+ )
+
+ try:
+ if msg_type == "error":
+ await persist_error_payload(data)
+ await websocket.send_json(data)
+ return True
+ except Exception as e:
+ logger.warning(
+ "[WS:%s] send_json failed for type=%s: %s",
+ session_id,
+ msg_type,
+ e,
+ exc_info=True,
+ )
+ if "close message" in str(e).lower() or "closed" in str(e).lower():
+ ws_closed = True
+ logger.debug("WebSocket closed, stopping sends")
+ return False
+
+ async def send_typed(msg: ServerMessage) -> bool:
+ """Send a typed protocol message to the client."""
+ return await safe_send_json(msg.model_dump(exclude_none=True))
+
+ async def send_typed_tool_lifecycle(
+ msg: ServerToolCall | ServerToolResult,
+ ) -> bool:
+ """Send tool lifecycle frames to the client."""
+ return await send_typed(msg)
+
+ ctx = None
+ session_title = ""
+ session_working_directory = "" # Will be set by client or loaded from metadata
+ session_pinned = False # Track pinned state for persistence
+ last_context_sent_directory = "" # Track when we last sent directory context
+ existing_history = None
+
+ # Use provided session_id or generate new one
+ if session_id:
+ # Validate session_id to prevent path traversal
+ try:
+ _validate_session_id(session_id)
+ except ValueError as e:
+ logger.warning("Invalid session_id rejected: %r: %s", session_id, e)
+ await websocket.close(code=1008, reason="Invalid session ID")
+ return
+
+ # Client wants to resume/continue a specific session
+ logger.debug("Client requested session: %s", session_id)
+
+ # SQLite is the source of truth — check session existence there.
+ try:
+ from code_puppy.api.db.queries import (
+ get_session_metadata,
+ session_exists,
+ )
+
+ if await session_exists(session_id):
+ existing_history = True
+ db_meta = await get_session_metadata(session_id) or {}
+ session_title = db_meta.get("title", "")
+ session_working_directory = db_meta.get("working_directory", "")
+ session_pinned = bool(db_meta.get("pinned", False))
+ except Exception as e:
+ logger.warning("Failed to check session in SQLite: %s", e)
+
+ if not existing_history:
+ logger.debug(
+ "Session %s not found in SQLite, will create new", session_id
+ )
+ else:
+ # Generate new timestamp-based session ID
+ session_timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
+ session_id = f"WS_session_{session_timestamp}"
+ logger.debug("Generated new session ID: %s", session_id)
+
+ try:
+ # --- SESSION ISOLATION: create or load via SessionManager ---
+ try:
+ if existing_history is not None:
+ # Session confirmed in SQLite — load it via SessionManager.
+ # get_or_load_session checks in-memory first, then falls
+ # back to a fresh SQLite load. SQLite is the sole source of truth.
+ ctx = await session_manager.get_or_load_session(session_id)
+ if ctx is None:
+ # SQLite confirmed this session exists but load returned None.
+ # This is unexpected (DB race, corrupt row, or schema mismatch).
+ # Log a warning and start fresh so the WebSocket can still
+ # function — silent data loss is worse than a blank session.
+ logger.warning(
+ "Session %s exists in SQLite but could not be loaded "
+ "(get_or_load_session returned None). "
+ "Starting a blank session to keep the connection alive.",
+ session_id,
+ )
+ ctx = await session_manager.create_session(session_id)
+ else:
+ # Update local state from loaded metadata
+ session_title = ctx.title
+ session_working_directory = ctx.working_directory
+ session_pinned = ctx.pinned
+ else:
+ ctx = await session_manager.create_session(session_id)
+ except Exception as e:
+ logger.warning("SessionManager init failed, falling back: %s", e)
+ try:
+ ctx = await session_manager.create_session(session_id)
+ except Exception:
+ logger.error("SessionManager fallback also failed", exc_info=True)
+ await websocket.close(code=1011, reason="Session init failed")
+ return
+
+ # Mark session as active (cancels any pending cleanup from previous disconnect)
+ await session_manager.mark_session_active(session_id)
+
+ # Initialize per-session process tracking (ContextVar isolation)
+ init_session_process_tracking()
+
+ # Set MessageBus session context for this WS connection
+ try:
+ bus = get_message_bus()
+ bus.set_session_context(session_id)
+ except Exception:
+ logger.debug("MessageBus session context not available")
+
+ # Convenience aliases — use ctx.agent everywhere instead of get_current_agent()
+ agent = ctx.agent
+ agent_name = ctx.agent_name
+ model_name = ctx.model_name
+
+ # ── Persist initial session context to SQLite (new sessions only) ───────
+ # Write a config row (agent + model) and, if a CWD is set, a directory
+ # row so the FE cold-load path sees them before the first user message.
+ # Skipped for resumed sessions — their init rows were written at creation.
+ if existing_history is None:
+ try:
+ _now_iso = datetime.datetime.now(datetime.timezone.utc).isoformat()
+ _init_agent = agent_name or "code-puppy"
+ _init_model = model_name or "unknown"
+ await write_system_message_to_sqlite(
+ session_id=session_id,
+ system_message_type="config",
+ content=f"🐶 Started with {_init_agent} ({_init_model})",
+ agent_name=_init_agent,
+ model_name=_init_model,
+ timestamp=_now_iso,
+ )
+ if session_working_directory:
+ _path_segments = session_working_directory.split("/")[-3:]
+ _relative = "/".join(s for s in _path_segments if s)
+ await write_system_message_to_sqlite(
+ session_id=session_id,
+ system_message_type="directory",
+ content=f"Starting in {_relative}",
+ system_message_path=session_working_directory,
+ agent_name=_init_agent,
+ model_name=_init_model,
+ timestamp=_now_iso,
+ )
+ except Exception as _init_exc:
+ logger.warning(
+ "Failed to write session init to SQLite: %s",
+ _init_exc,
+ exc_info=True,
+ )
+
+ # Send welcome message
+ await send_typed(
+ ServerSystem(
+ content=f"Connected! Session: {session_id}",
+ session_id=session_id,
+ agent_name=agent_name,
+ model_name=model_name,
+ resumed=existing_history is not None,
+ protocol_version=PROTOCOL_VERSION,
+ )
+ )
+
+ # If we resumed an existing session, notify the client
+ if existing_history and ctx:
+ try:
+ # History is already loaded into ctx.agent by session_manager.load_session().
+ # Pull it back out for the client-side UI metadata notification.
+ loaded_messages = ctx.agent.get_message_history()
+ message_count = len(loaded_messages) if loaded_messages else 0
+
+ # Notify client about restored session
+ await send_typed(
+ ServerSessionRestored(
+ session_id=session_id,
+ message_count=message_count,
+ title=session_title,
+ ui_metadata=[],
+ )
+ )
+
+ # Replay system messages (agent/model switches, CWD banners) to UI.
+ # These are persisted with pydantic_json=NULL so _load_from_sqlite
+ # skips them — we must send them separately so the UI shows them.
+ try:
+ from code_puppy.api.db.queries import get_active_messages
+
+ rows = await get_active_messages(session_id)
+ system_rows = [
+ r
+ for r in rows
+ if r.get("role") == "system"
+ and r.get("system_message_type")
+ in ("init", "config", "directory")
+ ]
+ for sys_row in system_rows:
+ await send_typed(
+ ServerSystem(
+ content=sys_row.get("content", ""),
+ session_id=session_id,
+ agent_name=sys_row.get("agent_name", ""),
+ model_name=sys_row.get("model_name", ""),
+ )
+ )
+ if system_rows:
+ logger.debug(
+ "Replayed %d system messages for session %s",
+ len(system_rows),
+ session_id,
+ )
+ except Exception as sys_exc:
+ logger.warning(
+ "Failed to replay system messages for session %s: %s",
+ session_id,
+ sys_exc,
+ )
+
+ agent = ctx.agent # Refresh local alias
+ logger.debug("Restored %d messages to session agent", message_count)
+ except Exception as e:
+ logger.warning("Failed to restore session history: %s", e)
+ # Track active streaming task for cancellation
+ active_drain_task = None
+ # Track active agent task (run_with_mcp) for cancellation
+ active_agent_task: asyncio.Task | None = None
+ # Track stop_draining event for cancellation across iterations
+ stop_draining = asyncio.Event()
+
+ while True:
+ try:
+ msg = await websocket.receive_json()
+
+ # Advisory validation — log but never reject
+ try:
+ _parsed = _ClientMessageAdapter.validate_python(msg)
+ except ValidationError as _val_err:
+ logger.warning(
+ "Client message failed validation: %s",
+ str(_val_err),
+ extra={
+ "type": msg.get("type")
+ if isinstance(msg, dict)
+ else "unknown"
+ },
+ )
+
+ if msg.get("type") == "switch_agent":
+ agent_name = msg.get("agent_name")
+ if agent_name:
+ try:
+ new_agent = await session_manager.switch_agent(
+ session_id, agent_name
+ )
+ agent = new_agent # Update local alias
+ ctx.agent = new_agent
+ model_name = (
+ new_agent.get_model_name()
+ if new_agent
+ else "unknown"
+ )
+
+ # Persist to SQLite so the FE cold-load path sees it
+ try:
+ await write_system_message_to_sqlite(
+ session_id=session_id,
+ system_message_type="config",
+ content=f"🔄 Switched to {agent_name} ({model_name})",
+ agent_name=agent_name,
+ model_name=model_name,
+ )
+ except Exception as _sw_exc:
+ logger.warning(
+ "Agent-switch SQLite write failed: %s",
+ _sw_exc,
+ exc_info=True,
+ )
+
+ await send_typed(
+ ServerSystem(
+ content=f"🔄 Switched to {agent_name} ({model_name})",
+ session_id=session_id,
+ agent_name=agent_name,
+ model_name=model_name,
+ )
+ )
+ except Exception as e:
+ logger.error("Error switching agent: %s", e)
+ await send_typed(
+ ServerError(
+ error=f"Failed to switch to agent {agent_name}: {str(e)}",
+ session_id=session_id,
+ )
+ )
+
+ elif msg.get("type") == "switch_model":
+ model_name = msg.get("model_name") or msg.get("model")
+ if model_name:
+ try:
+ await session_manager.switch_model(
+ session_id, model_name
+ )
+ agent = ctx.agent # Refresh local alias
+ logger.debug("Switched model to: %s", model_name)
+
+ # Persist to SQLite so the FE cold-load path sees it
+ _sw_agent = ctx.agent_name or agent_name or "code-puppy"
+ try:
+ await write_system_message_to_sqlite(
+ session_id=session_id,
+ system_message_type="config",
+ content=f"🔄 Switched to {_sw_agent} ({model_name})",
+ agent_name=_sw_agent,
+ model_name=model_name,
+ )
+ except Exception as _sw_exc:
+ logger.warning(
+ "Model-switch SQLite write failed: %s",
+ _sw_exc,
+ exc_info=True,
+ )
+
+ await send_typed(
+ ServerSystem(
+ content=f"🔄 Switched to {_sw_agent} ({model_name})",
+ session_id=session_id,
+ model_name=model_name,
+ agent_name=_sw_agent,
+ )
+ )
+ except Exception as e:
+ logger.error("Error switching model: %s", e)
+ await send_typed(
+ ServerError(
+ error=f"Failed to switch to model {model_name}: {str(e)}",
+ session_id=session_id,
+ )
+ )
+ else:
+ await send_typed(
+ ServerError(
+ error="No model_name provided for switch_model",
+ session_id=session_id,
+ )
+ )
+
+ elif msg.get("type") == "switch_session":
+ """
+ Switch to a different session without reconnecting WebSocket.
+
+ How it works:
+ 1. Client sends: {"type": "switch_session", "session_id": "WS_session_20260120_124356"}
+ 2. Server loads session from ~/.code_puppy/ws_sessions/{session_id}.pkl
+ 3. Server restores message history to the current agent
+ 4. Server responds with session_switched event containing message count and title
+ 5. Client can continue chatting with full conversation context
+
+ This approach keeps a single WebSocket connection alive while allowing
+ users to switch between multiple sessions instantly. All session data
+ stays on the local filesystem for privacy.
+ """
+ # Cancel any active streaming first
+ if active_drain_task and not active_drain_task.done():
+ logger.debug(
+ "Cancelling active streaming due to session switch"
+ )
+ stop_draining.set() # Signal drain to stop
+ active_drain_task.cancel()
+ try:
+ await active_drain_task
+ except asyncio.CancelledError:
+ pass
+ stop_draining.clear() # Reset for next streaming
+
+ new_session_id = msg.get("session_id")
+ if not new_session_id:
+ await send_typed(
+ ServerError(
+ error="No session_id provided for switch_session",
+ session_id=session_id,
+ )
+ )
+ continue
+
+ # Validate session_id to prevent path traversal
+ try:
+ _validate_session_id(new_session_id)
+ except ValueError as e:
+ logger.warning(
+ f"Invalid session_id in switch_session: {new_session_id!r}"
+ )
+ await send_typed(
+ ServerError(
+ error=f"Invalid session ID: {e}",
+ session_id=session_id,
+ )
+ )
+ continue
+
+ logger.debug("Switching to session: %s", new_session_id)
+
+ try:
+ # Check if target session exists in SQLite
+ try:
+ from code_puppy.api.db.queries import (
+ session_exists as _se,
+ )
+
+ _target_exists = await _se(new_session_id)
+ except Exception:
+ _target_exists = False
+
+ if not _target_exists:
+ # Session doesn't exist - create new one with this ID
+ logger.debug(
+ f"Session {new_session_id} not found, creating new"
+ )
+
+ # Save current session and mark inactive (keep in memory for 15 min)
+ try:
+ await session_manager.save_session(session_id)
+ except Exception:
+ pass
+ await session_manager.mark_session_inactive(session_id)
+
+ session_id = new_session_id
+ session_title = ""
+ session_working_directory = ""
+ session_pinned = False
+
+ ctx = await session_manager.create_session(session_id)
+ # Mark the new session as active
+ await session_manager.mark_session_active(session_id)
+ agent = ctx.agent
+ agent_name = ctx.agent_name
+ model_name = ctx.model_name
+
+ await send_typed(
+ ServerSessionSwitched(
+ session_id=new_session_id,
+ message_count=0,
+ title="",
+ created=True,
+ agent_name=agent_name,
+ model_name=model_name,
+ )
+ )
+ continue
+
+ # Load existing session via SessionManager
+ # Save current session and mark inactive (keep in memory for 15 min)
+ try:
+ await session_manager.save_session(session_id)
+ except Exception:
+ pass
+ await session_manager.mark_session_inactive(session_id)
+
+ session_id = new_session_id
+
+ # Try loading via SessionManager (checks in-memory first, then SQLite)
+ loaded_ctx = await session_manager.get_or_load_session(
+ session_id
+ )
+ if loaded_ctx is not None:
+ ctx = loaded_ctx
+ session_title = ctx.title
+ session_working_directory = ctx.working_directory
+ session_pinned = ctx.pinned
+ else:
+ # Legacy session or HMAC missing — load metadata
+ # from JSON (safe) and create fresh context
+ new_title = ""
+ new_working_directory = ""
+ new_pinned = False
+ try:
+ from code_puppy.api.db.queries import (
+ get_session_metadata as _gsm,
+ )
+
+ _sm = await _gsm(session_id) or {}
+ new_title = _sm.get("title", "")
+ new_working_directory = _sm.get(
+ "working_directory", ""
+ )
+ new_pinned = bool(_sm.get("pinned", False))
+ except Exception:
+ pass
+
+ ctx = await session_manager.create_session(session_id)
+ ctx.title = new_title
+ ctx.working_directory = new_working_directory
+ ctx.pinned = new_pinned
+ session_title = new_title
+ session_working_directory = new_working_directory
+ session_pinned = new_pinned
+
+ # Mark the new session as active
+ await session_manager.mark_session_active(session_id)
+
+ agent = ctx.agent
+ agent_name = ctx.agent_name
+ model_name = ctx.model_name
+ message_count = len(ctx.agent.get_message_history() or [])
+ logger.debug(
+ f"Restored {message_count} messages to session agent"
+ )
+
+ await send_typed(
+ ServerSessionSwitched(
+ session_id=new_session_id,
+ message_count=message_count,
+ title=session_title,
+ working_directory=session_working_directory,
+ created=False,
+ agent_name=agent_name,
+ model_name=model_name,
+ )
+ )
+
+ logger.debug(
+ f"Switched to session {new_session_id} with {message_count} messages"
+ )
+
+ except Exception as e:
+ logger.error("Error switching session: %s", e)
+ await send_typed(
+ ServerError(
+ error=f"Failed to switch to session {new_session_id}: {str(e)}",
+ session_id=session_id,
+ )
+ )
+
+ elif msg.get("type") == "set_working_directory":
+ new_directory = msg.get("directory", "")
+ if new_directory:
+ # Expand ~ and resolve symlinks before validation
+ new_directory = str(
+ Path(new_directory).expanduser().resolve()
+ )
+ logger.info(
+ "[CWD DEBUG] set_working_directory received: new=%r, current=%r, session=%s",
+ new_directory,
+ session_working_directory,
+ session_id,
+ )
+ # Validate the directory exists
+ if Path(new_directory).is_dir():
+ # Skip if directory hasn't actually changed (avoids duplicate banners on reload)
+ if new_directory == session_working_directory:
+ logger.info(
+ "[CWD DEBUG] Skipping unchanged directory: %r, session=%s",
+ new_directory,
+ session_id,
+ )
+ await send_typed(
+ ServerWorkingDirectoryChanged(
+ directory=new_directory,
+ success=True,
+ session_id=session_id,
+ unchanged=True,
+ )
+ )
+ continue # Skip to next message, don't write banner
+
+ session_working_directory = new_directory
+ logger.debug(
+ "Working directory set to: %s",
+ session_working_directory,
+ )
+
+ # NOTE: Do NOT append raw dict entries into agent
+ # message history here. The runtime expects typed
+ # ModelMessage objects; dict injection can corrupt
+ # subsequent turns and lead to result=None failures.
+
+ # Persist directory banner to SQLite so FE cold-load sees it
+ try:
+ # Bug 4: use ctx.agent_name / ctx.model_name directly
+ # so this works even when ctx.agent is None.
+ _cwd_agent = (
+ ctx.agent_name if ctx else None
+ ) or "code-puppy"
+ _cwd_model = (
+ ctx.model_name if ctx else None
+ ) or "unknown"
+ _cwd_segs = session_working_directory.split("/")[
+ -3:
+ ]
+ _cwd_rel = "/".join(s for s in _cwd_segs if s)
+ from code_puppy.config import get_puppy_name as _gpn
+
+ _pname = _gpn() or "puppy"
+ logger.info(
+ "[CWD DEBUG] Writing CWD banner to SQLite: path=%r, session=%s",
+ session_working_directory,
+ session_id,
+ )
+ await write_system_message_to_sqlite(
+ session_id=session_id,
+ system_message_type="directory",
+ content=f"{_pname} is now at {_cwd_rel}",
+ system_message_path=session_working_directory,
+ agent_name=_cwd_agent,
+ model_name=_cwd_model,
+ )
+ # Bug 5: also stamp working_directory on the sessions row
+ _now_cwd = datetime.datetime.now(
+ datetime.timezone.utc
+ ).isoformat()
+ await update_session_working_directory(
+ session_id=session_id,
+ working_directory=session_working_directory,
+ updated_at=_now_cwd,
+ )
+ except Exception as _cwd_exc:
+ logger.warning(
+ "CWD SQLite write failed: %s",
+ _cwd_exc,
+ exc_info=True,
+ )
+
+ await send_typed(
+ ServerWorkingDirectoryChanged(
+ directory=session_working_directory,
+ success=True,
+ session_id=session_id,
+ )
+ )
+ else:
+ await send_typed(
+ ServerWorkingDirectoryChanged(
+ directory=new_directory,
+ success=False,
+ error="Directory does not exist",
+ session_id=session_id,
+ )
+ )
+ else:
+ await send_typed(
+ ServerError(
+ error="No directory provided for set_working_directory",
+ session_id=session_id,
+ )
+ )
+
+ elif msg.get("type") == "update_session_meta":
+ # Update session metadata (pinned, title) — persisted to SQLite.
+ try:
+ # Update in-memory state (local vars AND SessionContext)
+ if "pinned" in msg and isinstance(msg["pinned"], bool):
+ session_pinned = msg["pinned"]
+ if ctx is not None:
+ ctx.pinned = session_pinned
+ if "title" in msg and isinstance(msg["title"], str):
+ session_title = msg["title"]
+ if ctx is not None:
+ ctx.title = session_title
+
+ # Persist to SQLite (single source of truth).
+ # Use a targeted UPDATE — NOT upsert_session() — so that
+ # message_count, total_tokens, and other stats are
+ # never zeroed out by a rename/pin operation.
+ try:
+ from datetime import datetime as _dt
+ from datetime import timezone as _tz
+
+ from code_puppy.api.db.queries import (
+ update_session_meta_fields,
+ )
+
+ await update_session_meta_fields(
+ session_id=session_id,
+ title=session_title,
+ pinned=session_pinned,
+ updated_at=_dt.now(_tz.utc).isoformat(),
+ )
+ logger.debug(
+ f"Updated session meta in SQLite for {session_id}: pinned={session_pinned}"
+ )
+ except Exception as meta_exc:
+ logger.warning(
+ "Failed to persist session meta to SQLite: %s",
+ meta_exc,
+ )
+
+ await send_typed(
+ ServerSessionMetaUpdated(
+ session_id=session_id,
+ pinned=session_pinned,
+ title=session_title,
+ )
+ )
+ except Exception as e:
+ logger.error("Error updating session meta: %s", e)
+ await send_typed(
+ ServerError(
+ error=f"Failed to update session metadata: {str(e)}",
+ session_id=session_id,
+ )
+ )
+
+ elif msg.get("type") == "get_config":
+ config_key = msg.get("key", "")
+ if config_key:
+ from code_puppy.config import get_value
+
+ value = get_value(config_key)
+ await send_typed(
+ ServerConfigValue(
+ key=config_key,
+ value=value,
+ session_id=session_id,
+ )
+ )
+ else:
+ await send_typed(
+ ServerError(
+ error="No key provided for get_config",
+ session_id=session_id,
+ )
+ )
+
+ elif msg.get("type") == "set_config":
+ config_key = msg.get("key", "")
+ config_value = msg.get("value", "")
+ if config_key:
+ from code_puppy.config import set_config_value
+
+ try:
+ set_config_value(config_key, str(config_value))
+ logger.debug(
+ f"Config set: {config_key} = {config_value}"
+ )
+ await send_typed(
+ ServerConfigValue(
+ key=config_key,
+ value=config_value,
+ success=True,
+ session_id=session_id,
+ )
+ )
+ except Exception as e:
+ await send_typed(
+ ServerError(
+ error=f"Failed to set config: {e}",
+ session_id=session_id,
+ )
+ )
+ else:
+ await send_typed(
+ ServerError(
+ error="No key provided for set_config",
+ session_id=session_id,
+ )
+ )
+
+ # Handle slash command execution
+ elif msg.get("type") == "command":
+ command_str = msg.get("command", "")
+ logger.debug("Command requested: %s", command_str)
+
+ try:
+ from io import StringIO
+
+ from rich.console import Console
+
+ from code_puppy.command_line.command_handler import (
+ get_commands_help,
+ handle_command,
+ )
+
+ output = None
+ captured_messages = []
+
+ # Special handling for /help - we can get the text directly
+ cmd_name = (
+ command_str.strip().lstrip("/").split()[0]
+ if command_str.strip()
+ else ""
+ )
+ if cmd_name in ("help", "h"):
+ # Get help text directly
+ help_text = get_commands_help()
+ # Render to plain text
+ output_buffer = StringIO()
+ console = Console(
+ file=output_buffer,
+ force_terminal=False,
+ width=100,
+ no_color=True,
+ )
+ console.print(help_text)
+ output = output_buffer.getvalue().strip()
+ result = True
+ else:
+ # For other commands, just execute and report success/failure
+ # The actual output goes to the message queue/terminal
+ result = handle_command(command_str)
+ # Some commands might return a string as output
+ if isinstance(result, str):
+ output = result
+ result = True
+
+ # Send command result
+ await send_typed(
+ ServerCommandResult(
+ command=command_str,
+ success=result is True or result is not False,
+ output=output,
+ messages=captured_messages,
+ result=str(result)
+ if result and result is not True
+ else None,
+ session_id=session_id,
+ )
+ )
+ logger.debug(
+ f"Command executed: {command_str} -> success={result is True or result is not False}, output_len={len(output) if output else 0}"
+ )
+
+ except Exception as cmd_error:
+ import traceback
+
+ error_details = traceback.format_exc()
+ logger.error("Command error: %s", cmd_error)
+ logger.error("Traceback: %s", error_details)
+ await send_typed(
+ ServerCommandResult(
+ command=command_str,
+ success=False,
+ error=str(cmd_error),
+ session_id=session_id,
+ )
+ )
+ continue
+
+ # Handle cancel/interrupt request
+ elif msg.get("type") == "cancel":
+ logger.debug(
+ "Cancel request received - stopping active streaming and agent task"
+ )
+
+ # Cancel any active streaming
+ if active_drain_task and not active_drain_task.done():
+ stop_draining.set() # Signal drain to stop
+ active_drain_task.cancel()
+ try:
+ await active_drain_task
+ except asyncio.CancelledError:
+ pass
+ stop_draining.clear() # Reset for next streaming
+ logger.debug("Active streaming cancelled")
+
+ # Cancel the in-flight agent run (run_with_mcp) if present
+ if active_agent_task and not active_agent_task.done():
+ logger.debug(
+ "Cancelling active agent task due to user interrupt"
+ )
+ active_agent_task.cancel()
+ try:
+ await active_agent_task
+ except asyncio.CancelledError:
+ logger.debug("Active agent task cancelled successfully")
+ active_agent_task = None
+
+ # Send confirmation
+ await send_typed(
+ ServerStatus(
+ status="cancelled",
+ session_id=session_id,
+ )
+ )
+ continue
+
+ elif msg.get("type") == "permission_response":
+ # Handle permission response from user
+ from code_puppy.api.permissions import (
+ handle_permission_response,
+ )
+
+ request_id = msg.get("request_id")
+ approved = msg.get("approved", False)
+
+ if request_id:
+ handled = handle_permission_response(request_id, approved)
+ if handled:
+ logger.debug(
+ f"[Permission] ✅ Handled response: {request_id} = {approved}"
+ )
+ else:
+ logger.warning(
+ f"[Permission] ❌ Unknown request: {request_id}"
+ )
+ else:
+ logger.error(
+ "[WebSocket] ❌ No request_id in permission_response!"
+ )
+ continue
+
+ elif msg.get("type") == "message":
+ # Set WebSocket context for permission requests
+ from code_puppy.api.permission_plugin import (
+ set_suppress_emitter_tool_events,
+ set_websocket_context,
+ )
+
+ set_websocket_context(websocket, session_id)
+
+ user_message = msg.get("content", "")
+
+ # Initialize attachment tracking variables at message scope
+ original_user_message = user_message # Store clean message
+ attachment_metadata = [] # Will be populated if attachments exist
+
+ # Check if a specific model was requested for this message
+ requested_model = msg.get("model")
+ if requested_model:
+ current_model = ctx.model_name
+ if requested_model != current_model:
+ try:
+ await session_manager.switch_model(
+ session_id, requested_model
+ )
+ agent = ctx.agent # Refresh alias
+ model_name = requested_model
+ logger.debug(
+ f"Switching to model {requested_model} for this message"
+ )
+ except Exception as e:
+ logger.warning("Failed to switch model: %s", e)
+
+ # Apply model_settings from frontend (reasoning_effort, verbosity, etc.)
+ model_settings = msg.get("model_settings", {})
+ if model_settings:
+ from code_puppy.config import set_model_setting
+
+ target_model = requested_model or get_global_model_name()
+ for setting_name, value in model_settings.items():
+ try:
+ set_model_setting(target_model, setting_name, value)
+ logger.debug(
+ f"Applied model_setting {setting_name}={value} for {target_model}"
+ )
+ except Exception as e:
+ logger.warning(
+ f"Failed to apply model_setting {setting_name}: {e}"
+ )
+
+ if not user_message.strip():
+ await send_typed(
+ ServerError(
+ error="Empty message",
+ session_id=session_id,
+ )
+ )
+ continue
+
+ logger.debug(
+ f"Chat message from client: {user_message[:50]}..."
+ )
+
+ # Echo the user message back
+ await send_typed(
+ ServerUserMessage(
+ content=user_message,
+ session_id=session_id,
+ )
+ )
+
+ # Get the session agent and process the message
+ try:
+ agent = ctx.agent
+
+ # Reload agent if a different model was requested
+ if requested_model:
+ agent.reload_code_generation_agent()
+ logger.debug(
+ f"Reloaded agent with model: {requested_model}"
+ )
+
+ if not agent:
+ await send_typed(
+ ServerError(
+ error="Agent not available. Please start Code Puppy first.",
+ session_id=session_id,
+ )
+ )
+ continue
+
+ # Subscribe to frontend emitter and run drain task CONCURRENTLY
+ event_queue = None
+ collected_text = []
+ stop_draining.clear() # Reset for this message
+ drain_task = None
+ active_parts: dict[
+ int, dict
+ ] = {} # Track message parts by index
+ tool_id_aliases: dict[str, str] = {}
+ tool_group_ids: dict[
+ str, str
+ ] = {} # tool_id -> tool_group_id mapping
+ b1_streaming_used = (
+ False # Track if B1 streaming sent any content
+ )
+ agent_error = None # Will hold exception if agent run fails
+
+ async def drain_events_concurrent(
+ ready_event: asyncio.Event = None,
+ ):
+ """Background task to drain events and send structured messages in real-time."""
+ nonlocal collected_text, b1_streaming_used, ws_closed
+ import time as time_module
+
+ # Capture agent and model metadata at the start.
+ # Chain `or` fallbacks so that an empty-string agent.name
+ # (possible before agent is fully initialised) still resolves
+ # to a meaningful value via the session context defaults.
+ current_agent_name = (
+ (agent.name if agent else "")
+ or ctx.agent_name
+ or "code-puppy"
+ )
+ current_model_name = (
+ (agent.get_model_name() if agent else "")
+ or ctx.model_name
+ or "unknown"
+ )
+ current_tool_name = None # Track active tool name
+ current_tool_group_id: str | None = (
+ None # Track current tool batch group ID
+ )
+
+ # Track pending tool calls for tool_result generation
+ # {tool_id: {tool_name, start_time, part_index}}
+ pending_tool_calls: dict[str, dict] = {}
+
+ def resolve_pending_tool_id(
+ *,
+ tool_call_id: str | None = None,
+ tool_name: str | None = None,
+ ) -> str | None:
+ """Resolve canonical frontend tool_id for a backend tool call reference."""
+ if (
+ tool_call_id
+ and tool_call_id in pending_tool_calls
+ ):
+ return tool_call_id
+
+ if tool_call_id:
+ for (
+ _pending_id,
+ _pending_info,
+ ) in pending_tool_calls.items():
+ if (
+ _pending_info.get("raw_tool_call_id")
+ == tool_call_id
+ ):
+ return _pending_id
+
+ if tool_name:
+ for (
+ _pending_id,
+ _pending_info,
+ ) in pending_tool_calls.items():
+ if (
+ _pending_info.get("tool_name")
+ == tool_name
+ ):
+ return _pending_id
+
+ return None
+
+ def resolve_tool_group_id(
+ *,
+ tool_id: str | None = None,
+ pending_info: dict | None = None,
+ fallback_group_id: str | None = None,
+ tool_name: str | None = None,
+ source: str = "unknown",
+ ) -> str:
+ """Return a non-empty tool_group_id for tool lifecycle frames.
+
+ Priority order:
+ 1) pending tool info
+ 2) tool_id -> group map
+ 3) explicit fallback passed by caller
+ 4) current in-flight batch group
+ 5) synthetic deterministic-ish fallback (last resort)
+ """
+ nonlocal current_tool_group_id
+
+ group_id = None
+ if pending_info is not None:
+ group_id = pending_info.get("tool_group_id")
+
+ if not group_id and tool_id:
+ group_id = tool_group_ids.get(tool_id)
+
+ if not group_id and fallback_group_id:
+ group_id = fallback_group_id
+
+ if not group_id:
+ group_id = current_tool_group_id
+
+ if not group_id:
+ raw_hint = tool_id or tool_name or "unknown"
+ stable_hint = (
+ re.sub(
+ r"[^a-z0-9_-]",
+ "-",
+ raw_hint.lower(),
+ )[:24].strip("-")
+ or "unknown"
+ )
+ group_id = f"tg-fallback-{stable_hint}-{str(uuid.uuid4())[:6]}"
+ logger.warning(
+ "[ws] Synthesized missing tool_group_id "
+ "for source=%s tool_id=%s tool_name=%s",
+ source,
+ tool_id,
+ tool_name,
+ )
+
+ if tool_id:
+ tool_group_ids[tool_id] = group_id
+ if pending_info is not None:
+ pending_info["tool_group_id"] = group_id
+ elif tool_id in pending_tool_calls:
+ pending_tool_calls[tool_id][
+ "tool_group_id"
+ ] = group_id
+
+ if current_tool_group_id is None:
+ current_tool_group_id = group_id
+
+ return group_id
+
+ event_count = 0
+ first_iteration = True
+ while not stop_draining.is_set():
+ # Exit if WebSocket is closed
+ if ws_closed:
+ logger.debug(
+ "WebSocket closed, exiting drain loop"
+ )
+ break
+ # Signal ready on first iteration (we're in the loop now)
+ if first_iteration and ready_event:
+ ready_event.set()
+ first_iteration = False
+ # Yield to allow run_with_mcp to start and emit first events
+ await asyncio.sleep(0)
+ continue # Re-enter loop to collect any queued events
+ # Event-driven batch collection with 10ms timeout for responsiveness.
+ # Instead of polling the clock, we use asyncio.wait_for to block until
+ # the first event arrives, then collect any additional events already queued.
+ events_to_send = []
+ try:
+ # Wait for first event with 10ms timeout (blocks efficiently)
+ first_event = await asyncio.wait_for(
+ event_queue.get(), timeout=0.01
+ )
+ events_to_send.append(first_event)
+ event_count += 1
+
+ # Collect any additional events already in queue (non-blocking)
+ # This keeps the batching benefit without polling the clock
+ while not event_queue.empty():
+ try:
+ event = event_queue.get_nowait()
+ events_to_send.append(event)
+ event_count += 1
+ except Exception:
+ break
+
+ except asyncio.TimeoutError:
+ # No events available within 10ms timeout
+ # No polling needed - asyncio.wait_for blocks efficiently
+ pass
+
+ # Log batch composition for debugging
+ if events_to_send:
+ event_types = {}
+ for e in events_to_send:
+ et = e.get("type", "unknown")
+ event_types[et] = event_types.get(et, 0) + 1
+ logger.debug(
+ "[%s] Batch: %d events - %s",
+ session_id,
+ len(events_to_send),
+ event_types,
+ )
+
+ # Process collected events
+ for event in events_to_send:
+ # If cancellation was requested, stop processing immediately
+ if stop_draining.is_set():
+ logger.debug(
+ "stop_draining set during batch - stopping event processing"
+ )
+ break
+
+ event_type = event.get("type", "")
+ event_data = event.get("data", {})
+
+ try:
+ # Handle tool call events
+ if event_type == "tool_call_start":
+ tool_name = event_data.get(
+ "tool_name", "unknown"
+ )
+ tool_args = event_data.get(
+ "tool_args", {}
+ )
+ tool_id = str(uuid.uuid4())[:8]
+
+ # Generate tool group ID for this batch if not already set
+ if current_tool_group_id is None:
+ current_tool_group_id = (
+ f"tg-{str(uuid.uuid4())[:8]}"
+ )
+
+ logger.debug(
+ "[ws] tool_call: %s", tool_name
+ )
+
+ # Track current tool name
+ current_tool_name = tool_name
+
+ # Register in pending_tool_calls so
+ # tool_call_complete can echo back the
+ # same tool_id the frontend already knows.
+ pending_tool_calls[tool_id] = {
+ "tool_name": tool_name,
+ "start_time": time_module.time(),
+ "tool_group_id": current_tool_group_id,
+ }
+
+ await send_typed_tool_lifecycle(
+ ServerToolCall(
+ tool_id=tool_id,
+ tool_name=tool_name,
+ args=tool_args,
+ timestamp=time_module.time(),
+ session_id=session_id,
+ agent_name=current_agent_name,
+ model_name=current_model_name,
+ tool_group_id=current_tool_group_id,
+ )
+ )
+
+ elif event_type == "tool_call_complete":
+ tool_name = event_data.get(
+ "tool_name", "unknown"
+ )
+ result = event_data.get(
+ "result",
+ event_data.get(
+ "result_summary", ""
+ ),
+ )
+ success = event_data.get(
+ "success", True
+ )
+ duration = event_data.get(
+ "duration_ms", 0
+ )
+
+ logger.debug(
+ "[ws] tool_result: %s", tool_name
+ )
+
+ # Clear current tool name after completion
+ current_tool_name = None
+
+ # Match the tool_id we generated at
+ # tool_call_start so the frontend can
+ # correlate result → call by ID, not
+ # just by name (names are not unique).
+ matching_tool_id = next(
+ (
+ tid
+ for tid, info in pending_tool_calls.items()
+ if info["tool_name"]
+ == tool_name
+ ),
+ None,
+ )
+ matching_pending_info = (
+ pending_tool_calls.get(
+ matching_tool_id
+ )
+ if matching_tool_id
+ else None
+ )
+ tool_group_id_for_result = resolve_tool_group_id(
+ tool_id=matching_tool_id,
+ pending_info=matching_pending_info,
+ fallback_group_id=current_tool_group_id,
+ tool_name=tool_name,
+ source="tool_call_complete",
+ )
+
+ if matching_tool_id:
+ pending_tool_calls.pop(
+ matching_tool_id, None
+ )
+
+ await send_typed_tool_lifecycle(
+ ServerToolResult(
+ tool_id=matching_tool_id,
+ tool_name=tool_name,
+ result=result,
+ success=success,
+ duration_ms=duration,
+ timestamp=time_module.time(),
+ session_id=session_id,
+ agent_name=current_agent_name,
+ model_name=current_model_name,
+ tool_group_id=tool_group_id_for_result,
+ )
+ )
+
+ elif event_type == "agent_invoked":
+ agent_name_inv = event_data.get(
+ "agent_name", "unknown"
+ )
+ prompt_preview = event_data.get(
+ "prompt_preview", ""
+ )
+
+ logger.debug(
+ "[ws] agent_invoked: %s",
+ agent_name_inv,
+ )
+
+ await send_typed(
+ ServerAgentInvoked(
+ agent_name=agent_name_inv,
+ prompt_preview=prompt_preview,
+ timestamp=time_module.time(),
+ session_id=session_id,
+ )
+ )
+
+ # Handle streaming events
+ elif event_type == "stream_event":
+ inner_type = event_data.get(
+ "event_type", ""
+ )
+ inner_data = event_data.get(
+ "event_data", {}
+ )
+ if inner_type == "part_start":
+ part_index = inner_data.get(
+ "index", 0
+ )
+ part_type = inner_data.get(
+ "part_type", "unknown"
+ )
+ logger.warning(
+ "[ws] part_start: part_type=%s, part_index=%s",
+ part_type,
+ part_index,
+ )
+
+ # Extract initial content from the part if present
+ # The part object may have content already (especially for TextPart/ThinkingPart)
+ part_obj = inner_data.get(
+ "part", {}
+ )
+ initial_content = ""
+
+ if (
+ hasattr(part_obj, "content")
+ and part_obj.content
+ ):
+ initial_content = (
+ part_obj.content
+ )
+ elif isinstance(
+ part_obj, dict
+ ) and part_obj.get("content"):
+ initial_content = part_obj.get(
+ "content", ""
+ )
+
+ if part_type in (
+ "TextPart",
+ "ThinkingPart",
+ ):
+ msg_type = (
+ "thinking"
+ if part_type
+ == "ThinkingPart"
+ else "text"
+ )
+
+ # When a TextPart starts, any pending tool calls have completed
+ # Send status-only tool_result during streaming to update UI
+ # The actual result data will be sent later from extraction code
+ if (
+ pending_tool_calls
+ and part_type == "TextPart"
+ ):
+ for (
+ tool_id,
+ tool_info,
+ ) in list(
+ pending_tool_calls.items()
+ ):
+ if tool_info.get(
+ "status_only_sent"
+ ):
+ continue
+ duration_ms = (
+ time_module.time()
+ - tool_info[
+ "start_time"
+ ]
+ ) * 1000
+ logger.debug(
+ f"[WebSocket] Sending status-only tool_result for: {tool_info['tool_name']} (duration: {duration_ms:.1f}ms)"
+ )
+ await send_typed(
+ ServerToolResult(
+ tool_id=tool_id,
+ result={
+ "_status": "completed",
+ "_pending_full_result": True,
+ },
+ tool_name=tool_info[
+ "tool_name"
+ ],
+ success=True,
+ duration_ms=duration_ms,
+ timestamp=time_module.time(),
+ session_id=session_id,
+ agent_name=current_agent_name,
+ model_name=current_model_name,
+ tool_group_id=resolve_tool_group_id(
+ tool_id=tool_id,
+ pending_info=tool_info,
+ fallback_group_id=current_tool_group_id,
+ tool_name=tool_info.get(
+ "tool_name"
+ ),
+ source="status_only_result",
+ ),
+ )
+ )
+ tool_info[
+ "status_only_sent"
+ ] = True
+ current_tool_name = None
+
+ # Check if part already exists (created by early delta)
+ if part_index in active_parts:
+ # Just update the type, keep existing message_id
+ active_parts[part_index][
+ "type"
+ ] = msg_type
+ message_id = active_parts[
+ part_index
+ ]["id"]
+ # If there's initial content, add it to the existing accumulated content
+ if initial_content:
+ active_parts[
+ part_index
+ ]["content"] = (
+ initial_content
+ + active_parts[
+ part_index
+ ]["content"]
+ )
+ collected_text.insert(
+ 0, initial_content
+ ) # Prepend to collected text too
+ logger.debug(
+ f"[Stream Debug] Part already exists, reusing message_id={message_id}"
+ )
+ # Don't send another start event
+ continue
+
+ message_id = f"msg-{int(time_module.time() * 1000)}-{part_index}"
+ active_parts[part_index] = {
+ "id": message_id,
+ "type": msg_type,
+ "content": initial_content, # Start with initial content if present
+ }
+
+ # If there's initial content, add it to collected_text
+ if initial_content:
+ collected_text.append(
+ initial_content
+ )
+
+ # New assistant output part indicates turn boundary for tool grouping
+ if part_index == 0:
+ current_tool_group_id = None
+
+ await safe_send_json(
+ ServerAssistantMessageStart(
+ message_id=message_id,
+ part_type=msg_type,
+ part_index=part_index,
+ timestamp=time_module.time(),
+ session_id=session_id,
+ agent_name=current_agent_name,
+ model_name=current_model_name,
+ tool_name=current_tool_name,
+ ).model_dump(
+ exclude_none=True
+ )
+ )
+
+ # If there's initial content, send it as a delta immediately
+ if initial_content:
+ _delta = ServerAssistantMessageDelta.model_construct(
+ type="assistant_message_delta",
+ message_id=message_id,
+ content=initial_content,
+ part_index=part_index,
+ session_id=session_id,
+ agent_name=current_agent_name,
+ model_name=current_model_name,
+ tool_name=current_tool_name,
+ )
+ await safe_send_json(
+ _delta.model_dump(
+ exclude_none=True
+ )
+ )
+ # Mark that B1 streaming was used (nonlocal already declared above)
+ b1_streaming_used = True
+
+ elif part_type == "ToolCallPart":
+ # Extract tool info from the part
+ tool_name = "unknown"
+ tool_call_id = None
+ tool_args_str = ""
+
+ # Extract tool info from part_obj (dict or object)
+ if hasattr(
+ part_obj, "tool_name"
+ ):
+ tool_name = (
+ part_obj.tool_name
+ )
+ elif isinstance(part_obj, dict):
+ tool_name = part_obj.get(
+ "tool_name", "unknown"
+ )
+
+ if hasattr(
+ part_obj, "tool_call_id"
+ ):
+ tool_call_id = (
+ part_obj.tool_call_id
+ )
+ elif isinstance(part_obj, dict):
+ tool_call_id = part_obj.get(
+ "tool_call_id"
+ )
+
+ if hasattr(part_obj, "args"):
+ tool_args_str = (
+ part_obj.args or ""
+ )
+ elif isinstance(part_obj, dict):
+ tool_args_str = (
+ part_obj.get("args", "")
+ or ""
+ )
+
+ tool_id = (
+ tool_call_id
+ or str(uuid.uuid4())[:8]
+ )
+
+ # Track this tool call part with args buffer for accumulation
+ # Don't send tool_call yet - wait for part_end when args are complete
+ active_parts[part_index] = {
+ "id": tool_id,
+ "raw_tool_call_id": tool_call_id,
+ "type": "tool_call",
+ "tool_name": tool_name,
+ "args": tool_args_str,
+ "args_buffer": tool_args_str, # Buffer for accumulating args_delta
+ "start_time": time_module.time(), # Track when tool started
+ }
+
+ # Track current tool name
+ current_tool_name = tool_name
+
+ logger.debug(
+ f"[WebSocket] ToolCallPart started: {tool_name} (id: {tool_id})"
+ )
+ # tool_call event will be sent on part_end when args are complete
+
+ elif part_type == "ToolReturnPart":
+ logger.info(
+ f"[WebSocket] ToolReturnPart detected! part_index={part_index}"
+ )
+ # Extract tool result info from the part
+ tool_call_id = None
+ tool_content = None
+
+ # Extract tool_call_id
+ if hasattr(
+ part_obj, "tool_call_id"
+ ):
+ tool_call_id = (
+ part_obj.tool_call_id
+ )
+ elif isinstance(part_obj, dict):
+ tool_call_id = part_obj.get(
+ "tool_call_id"
+ )
+
+ # Extract content (the tool result)
+ if hasattr(part_obj, "content"):
+ tool_content = (
+ part_obj.content
+ )
+ elif isinstance(part_obj, dict):
+ tool_content = part_obj.get(
+ "content"
+ )
+
+ # Try to serialize complex result objects
+ if (
+ tool_content
+ and not isinstance(
+ tool_content,
+ (
+ str,
+ dict,
+ list,
+ int,
+ float,
+ bool,
+ type(None),
+ ),
+ )
+ ):
+ try:
+ import json
+
+ # Try to convert to dict first (for Pydantic models)
+ if hasattr(
+ tool_content,
+ "model_dump",
+ ):
+ tool_content = tool_content.model_dump()
+ elif hasattr(
+ tool_content, "dict"
+ ):
+ tool_content = tool_content.dict()
+ elif hasattr(
+ tool_content,
+ "__dict__",
+ ):
+ tool_content = tool_content.__dict__
+ else:
+ tool_content = str(
+ tool_content
+ )
+ except Exception as e:
+ logger.debug(
+ f"[WebSocket] Could not serialize tool result: {e}"
+ )
+ tool_content = str(
+ tool_content
+ )
+
+ # Find the corresponding pending tool call, store and SEND the result
+ _result_sent = False
+ if tool_call_id:
+ resolved_pending_id = resolve_pending_tool_id(
+ tool_call_id=tool_call_id
+ )
+ if resolved_pending_id:
+ _result_sent = True
+ pending_info = pending_tool_calls[
+ resolved_pending_id
+ ]
+ pending_info[
+ "result"
+ ] = tool_content
+ _tool_name = (
+ pending_info.get(
+ "tool_name",
+ "unknown",
+ )
+ )
+ _start_time = pending_info.get(
+ "start_time",
+ time_module.time(),
+ )
+ _duration_ms = (
+ time_module.time()
+ - _start_time
+ ) * 1000
+ _group_id = resolve_tool_group_id(
+ tool_id=resolved_pending_id,
+ pending_info=pending_info,
+ fallback_group_id=current_tool_group_id,
+ tool_name=_tool_name,
+ source="tool_return_resolved",
+ )
+ logger.info(
+ f"[WebSocket] ToolReturnPart: Sending result for {_tool_name} (id: {resolved_pending_id}, raw: {tool_call_id})"
+ )
+ # Send the REAL tool_result with actual content
+ await send_typed_tool_lifecycle(
+ ServerToolResult(
+ tool_id=resolved_pending_id,
+ tool_name=_tool_name,
+ result=tool_content,
+ success=True,
+ duration_ms=_duration_ms,
+ timestamp=time_module.time(),
+ session_id=session_id,
+ agent_name=current_agent_name
+ or "code-puppy",
+ model_name=current_model_name
+ or "unknown",
+ tool_group_id=_group_id,
+ )
+ )
+ else:
+ logger.warning(
+ f"[WebSocket] ToolReturnPart: Could not resolve tool_call_id {tool_call_id}, pending keys: {list(pending_tool_calls.keys())}"
+ )
+
+ # Fallback: proximity-based matching if no tool_call_id or resolution failed
+ if not _result_sent:
+ for (
+ pending_id,
+ pending_info,
+ ) in sorted(
+ pending_tool_calls.items(),
+ key=lambda x: abs(
+ x[1].get(
+ "part_index",
+ 9999,
+ )
+ - part_index
+ ),
+ ):
+ if (
+ abs(
+ pending_info.get(
+ "part_index",
+ 9999,
+ )
+ - part_index
+ )
+ <= 3
+ ):
+ # Close enough - assume it's the result for this tool call
+ pending_info[
+ "result"
+ ] = tool_content
+ _tool_name = pending_info.get(
+ "tool_name",
+ "unknown",
+ )
+ _start_time = pending_info.get(
+ "start_time",
+ time_module.time(),
+ )
+ _duration_ms = (
+ time_module.time()
+ - _start_time
+ ) * 1000
+ _group_id = resolve_tool_group_id(
+ tool_id=pending_id,
+ pending_info=pending_info,
+ fallback_group_id=current_tool_group_id,
+ tool_name=_tool_name,
+ source="tool_return_proximity",
+ )
+ logger.info(
+ f"[WebSocket] ToolReturnPart: Sending result (by proximity) for {_tool_name} (id: {pending_id})"
+ )
+ # Send the REAL tool_result with actual content
+ await send_typed_tool_lifecycle(
+ ServerToolResult(
+ tool_id=pending_id,
+ tool_name=_tool_name,
+ result=tool_content,
+ success=True,
+ duration_ms=_duration_ms,
+ timestamp=time_module.time(),
+ session_id=session_id,
+ agent_name=current_agent_name
+ or "code-puppy",
+ model_name=current_model_name
+ or "unknown",
+ tool_group_id=_group_id,
+ )
+ )
+ _result_sent = True
+ break
+
+ # Warn if we couldn't send the result
+ if not _result_sent:
+ logger.warning(
+ f"[WebSocket] ToolReturnPart: Could NOT send result! tool_call_id={tool_call_id}, pending_tool_calls={list(pending_tool_calls.keys())}, part_index={part_index}"
+ )
+
+ # Track this part for cleanup
+ active_parts[part_index] = {
+ "id": f"tool-return-{part_index}",
+ "type": "tool_return",
+ "tool_call_id": tool_call_id,
+ "content": tool_content,
+ }
+
+ elif inner_type == "part_delta":
+ part_index = inner_data.get(
+ "index", 0
+ )
+ delta_type = inner_data.get(
+ "delta_type", ""
+ )
+ delta_obj = inner_data.get(
+ "delta", {}
+ )
+
+ # Handle ToolCallPartDelta - accumulate args
+ if (
+ delta_type
+ == "ToolCallPartDelta"
+ ):
+ args_delta = ""
+ if hasattr(
+ delta_obj, "args_delta"
+ ):
+ args_delta = (
+ delta_obj.args_delta
+ or ""
+ )
+ elif isinstance(
+ delta_obj, dict
+ ):
+ args_delta = (
+ delta_obj.get(
+ "args_delta", ""
+ )
+ or ""
+ )
+
+ if (
+ args_delta
+ and part_index
+ in active_parts
+ ):
+ part_info = active_parts[
+ part_index
+ ]
+ if (
+ part_info.get("type")
+ == "tool_call"
+ ):
+ part_info[
+ "args_buffer"
+ ] = (
+ part_info.get(
+ "args_buffer",
+ "",
+ )
+ + args_delta
+ )
+ continue # Don't process as text delta
+
+ # Extract content_delta (supports both direct and nested formats)
+ content_delta = inner_data.get(
+ "content_delta", ""
+ )
+ if not content_delta:
+ if isinstance(delta_obj, dict):
+ content_delta = (
+ delta_obj.get(
+ "content_delta", ""
+ )
+ )
+
+ if content_delta:
+ collected_text.append(
+ content_delta
+ )
+
+ # Ensure we have an entry for this part (handles delta before start)
+ if (
+ part_index
+ not in active_parts
+ ):
+ # Create entry with consistent message_id
+ message_id = f"msg-{int(time_module.time() * 1000)}-{part_index}"
+ active_parts[part_index] = {
+ "id": message_id,
+ "type": "text", # Default, will be updated by part_start if it arrives
+ "content": "",
+ }
+ # Send a start event so client creates the message
+ logger.debug(
+ f"[Stream Debug] Creating part on first delta: message_id={message_id}"
+ )
+ if part_index == 0:
+ current_tool_group_id = None
+ await safe_send_json(
+ ServerAssistantMessageStart(
+ message_id=message_id,
+ part_type="text",
+ part_index=part_index,
+ timestamp=time_module.time(),
+ session_id=session_id,
+ agent_name=current_agent_name,
+ model_name=current_model_name,
+ tool_name=current_tool_name,
+ ).model_dump(
+ exclude_none=True
+ )
+ )
+
+ part_info = active_parts[
+ part_index
+ ]
+ message_id = part_info["id"]
+
+ if part_index in active_parts:
+ active_parts[part_index][
+ "content"
+ ] += content_delta
+
+ _delta = ServerAssistantMessageDelta.model_construct(
+ type="assistant_message_delta",
+ message_id=message_id,
+ content=content_delta,
+ part_index=part_index,
+ session_id=session_id,
+ agent_name=current_agent_name,
+ model_name=current_model_name,
+ tool_name=current_tool_name,
+ )
+ await safe_send_json(
+ _delta.model_dump(
+ exclude_none=True
+ )
+ )
+ # Mark that B1 streaming was used
+ b1_streaming_used = True
+
+ elif inner_type == "part_end":
+ part_index = inner_data.get(
+ "index", 0
+ )
+ part_info = active_parts.get(
+ part_index, {}
+ )
+ part_type_info = part_info.get(
+ "type", "text"
+ )
+ message_id = part_info.get(
+ "id", f"msg-{part_index}"
+ )
+ full_content = part_info.get(
+ "content", ""
+ )
+
+ # Handle tool_call parts - send tool_call event now that args are complete
+ if part_type_info == "tool_call":
+ tool_name = part_info.get(
+ "tool_name", "unknown"
+ )
+ tool_id = part_info.get(
+ "id", str(uuid.uuid4())[:8]
+ )
+ tool_args_str = part_info.get(
+ "args_buffer", ""
+ ) or part_info.get("args", "")
+ start_time = part_info.get(
+ "start_time",
+ time_module.time(),
+ )
+ raw_tool_call_id = (
+ part_info.get(
+ "raw_tool_call_id"
+ )
+ )
+
+ # Parse args if they're a JSON string
+ try:
+ import json
+
+ args_dict = (
+ json.loads(
+ tool_args_str
+ )
+ if tool_args_str
+ else {}
+ )
+ except (
+ json.JSONDecodeError,
+ TypeError,
+ ):
+ args_dict = {}
+
+ logger.debug(
+ f"[WebSocket] Sending tool_call (args complete): {tool_name}"
+ )
+
+ # Generate tool group ID for this batch if not already set
+ if (
+ current_tool_group_id
+ is None
+ ):
+ current_tool_group_id = f"tg-{str(uuid.uuid4())[:8]}"
+
+ # Send tool_call now that we have complete args
+ await send_typed_tool_lifecycle(
+ ServerToolCall(
+ tool_id=tool_id,
+ tool_name=tool_name,
+ args=args_dict,
+ timestamp=time_module.time(),
+ session_id=session_id,
+ agent_name=current_agent_name,
+ model_name=current_model_name,
+ tool_group_id=current_tool_group_id,
+ )
+ )
+
+ # Track as pending tool call for later tool_result
+ pending_tool_calls[tool_id] = {
+ "tool_name": tool_name,
+ "start_time": start_time,
+ "part_index": part_index,
+ "raw_tool_call_id": raw_tool_call_id,
+ "status_only_sent": False,
+ "result": None, # Will be populated by ToolReturnPart
+ "tool_group_id": current_tool_group_id,
+ }
+ if raw_tool_call_id:
+ tool_id_aliases[
+ raw_tool_call_id
+ ] = tool_id
+ # Also track tool_group_id for pre-stream_end extraction
+ if current_tool_group_id:
+ tool_group_ids[tool_id] = (
+ current_tool_group_id
+ )
+
+ # Clear current tool name tracking
+ current_tool_name = None
+ else:
+ await safe_send_json(
+ ServerAssistantMessageEnd(
+ message_id=message_id,
+ part_index=part_index,
+ full_content=full_content,
+ timestamp=time_module.time(),
+ session_id=session_id,
+ agent_name=current_agent_name,
+ model_name=current_model_name,
+ tool_name=current_tool_name,
+ ).model_dump(
+ exclude_none=True
+ )
+ )
+
+ if part_index in active_parts:
+ del active_parts[part_index]
+
+ except Exception as send_err:
+ error_msg = str(send_err).lower()
+ if (
+ "close message" in error_msg
+ or "closed" in error_msg
+ ):
+ ws_closed = True
+ logger.debug(
+ "WebSocket closed during streaming, stopping drain"
+ )
+ break
+ logger.warning(
+ f"Error sending event to WebSocket: {type(send_err).__name__}: {send_err}"
+ )
+ import traceback
+
+ logger.warning(
+ f"Traceback: {traceback.format_exc()}"
+ )
+
+ # No idle polling - event-driven approach handles empty event sets gracefully
+
+ # Final drain after stop signal
+ final_count = 0
+ while True:
+ try:
+ event = event_queue.get_nowait()
+ event_type = event.get("type", "")
+ event_data = event.get("data", {})
+ final_count += 1
+
+ if event_type == "stream_event":
+ inner_type = event_data.get(
+ "event_type", ""
+ )
+ inner_data = event_data.get(
+ "event_data", {}
+ )
+ if inner_type == "part_delta":
+ content_delta = inner_data.get(
+ "content_delta", ""
+ )
+ if not content_delta:
+ delta_obj = inner_data.get(
+ "delta", {}
+ )
+ if isinstance(delta_obj, dict):
+ content_delta = delta_obj.get(
+ "content_delta", ""
+ )
+ if content_delta:
+ collected_text.append(content_delta)
+ except Exception:
+ break
+
+ # Calculate batching efficiency
+ avg_batch_size = (
+ event_count / max(1, event_count)
+ if event_count > 0
+ else 0
+ )
+ logger.debug(
+ f"[{session_id}] Drain complete: "
+ f"{event_count} events during run, {final_count} final, "
+ f"batching efficiency: {avg_batch_size:.2f}"
+ )
+
+ # Event to signal drain task is ready
+ drain_ready = asyncio.Event()
+
+ try:
+ from code_puppy.plugins.frontend_emitter.emitter import (
+ subscribe,
+ unsubscribe,
+ )
+
+ event_queue = subscribe(session_id=session_id)
+ logger.debug(
+ "Subscribed to frontend emitter for streaming"
+ )
+
+ # Modify drain function to signal when ready
+ async def drain_events_with_signal():
+ """Wrapper that signals readiness before starting drain loop."""
+ await drain_events_concurrent(drain_ready)
+
+ # Start concurrent drain task
+ drain_task = asyncio.create_task(
+ drain_events_with_signal()
+ )
+ active_drain_task = (
+ drain_task # Track for potential cancellation
+ )
+
+ # Wait for drain task to be ready before proceeding
+ await drain_ready.wait()
+
+ except ImportError:
+ logger.warning("Frontend emitter not available")
+
+ try:
+ # Send status: thinking
+ await send_typed(
+ ServerStatus(
+ status="thinking",
+ session_id=session_id,
+ agent_name=agent.name
+ if agent
+ else "code-puppy",
+ model_name=agent.get_model_name()
+ if agent
+ else "unknown",
+ )
+ )
+
+ # Change to session working directory if set
+ # Set session context for prompt generation (desk-puppy)
+ set_session_working_directory(session_working_directory)
+
+ try:
+ # Call run_with_mcp (drain task runs concurrently!)
+ logger.debug(
+ "About to run agent, working_directory=%s",
+ session_working_directory,
+ )
+
+ # Inject working directory as a system message in the
+ # agent's conversation history (once per directory change)
+ # so the LLM knows the CWD without polluting user messages.
+ message_to_send = user_message
+ if (
+ session_working_directory
+ and session_working_directory
+ != last_context_sent_directory
+ ):
+ from pydantic_ai.messages import (
+ ModelRequest,
+ SystemPromptPart,
+ )
+
+ wd_system_msg = ModelRequest(
+ parts=[
+ SystemPromptPart(
+ content=(
+ f"The user's current working directory is updated to"
+ f" {session_working_directory}"
+ )
+ )
+ ]
+ )
+ agent.append_to_message_history(wd_system_msg)
+ last_context_sent_directory = (
+ session_working_directory
+ )
+ logger.debug(
+ "Injected working directory system message: %s",
+ session_working_directory,
+ )
+
+ logger.debug(
+ f"Calling run_with_mcp with message: {message_to_send[:100]}..."
+ )
+
+ # Build file context and binary attachments
+ file_context, binary_attachments = (
+ build_file_context_and_attachments(msg)
+ )
+
+ # CHANGE 1: Update attachment metadata for UI (variables already initialized)
+ if msg.get("attachments"):
+ from pathlib import Path as _AttachmentPath
+
+ for raw_path in msg.get("attachments", []):
+ if (
+ isinstance(raw_path, str)
+ and raw_path.strip()
+ ):
+ try:
+ file_path = _AttachmentPath(
+ raw_path
+ )
+ if file_path.exists():
+ attachment_metadata.append(
+ {
+ "name": file_path.name,
+ "path": str(
+ file_path.absolute()
+ ),
+ "sizeBytes": file_path.stat().st_size,
+ }
+ )
+ except Exception as e:
+ logger.warning(
+ f"Error building attachment metadata for '{raw_path}': {e}"
+ )
+
+ # Prepend file context to the message
+ if file_context:
+ message_to_send = (
+ file_context + "\n\n" + message_to_send
+ )
+ logger.debug(
+ f"Added file context ({len(file_context)} chars)"
+ )
+
+ run_kwargs = {}
+ if binary_attachments:
+ run_kwargs["attachments"] = binary_attachments
+ logger.debug(
+ f"Including {len(binary_attachments)} binary attachment(s)"
+ )
+
+ # ──────────────────────────────────────────────────────────────────
+ # Phase 7: Create session + user message in SQLite BEFORE streaming
+ # This prevents "Session not found" errors when FE queries mid-stream
+ # ──────────────────────────────────────────────────────────────────
+ try:
+ import os
+ from datetime import timezone as tz
+
+ from code_puppy.api.db.queries import (
+ upsert_session,
+ )
+
+ now_iso = datetime.datetime.now(
+ tz.utc
+ ).isoformat()
+
+ # Ensure session row exists
+ await upsert_session(
+ session_id=session_id,
+ title="", # Will be updated later with actual title
+ agent_name=ctx.agent_name,
+ model_name=ctx.model_name,
+ working_directory=os.getcwd(),
+ pinned=False,
+ created_at=now_iso,
+ updated_at=now_iso,
+ message_count=0, # Will be updated after streaming
+ total_tokens=0,
+ )
+
+ # Write user message immediately (before agent processes it)
+ # Use get_next_seq() + insert_message() so the user message
+ # lands at MAX(seq)+1, AFTER any system messages (config,
+ # directory banners) that were already written at seq=1, 2, …
+ # The old write_turn_to_sqlite([single_item]) always assigned
+ # seq=1, silently colliding with those rows via INSERT OR IGNORE.
+ from pydantic_ai.messages import (
+ ModelRequest,
+ UserPromptPart,
+ )
+
+ from code_puppy.api.db.message_utils import (
+ pydantic_json_for_message,
+ )
+ from code_puppy.api.db.queries import (
+ get_next_seq,
+ insert_message,
+ )
+
+ user_msg_obj = ModelRequest(
+ parts=[
+ UserPromptPart(
+ content=original_user_message
+ )
+ ]
+ )
+
+ user_seq = await get_next_seq(session_id)
+ await insert_message(
+ session_id=session_id,
+ seq=user_seq,
+ role="user",
+ content=original_user_message,
+ type="ModelRequest",
+ agent_name=ctx.agent_name,
+ model_name=ctx.model_name,
+ timestamp=now_iso,
+ clean_content=original_user_message,
+ attachments_json=(
+ json.dumps(attachment_metadata)
+ if attachment_metadata
+ else None
+ ),
+ pydantic_json=pydantic_json_for_message(
+ user_msg_obj
+ ),
+ )
+
+ logger.debug(
+ "Pre-stream write: session %s created with user message in SQLite",
+ session_id,
+ )
+ except Exception as pre_write_exc:
+ # Non-fatal: WS streaming will still work, SQLite just won't have the
+ # session yet. The post-stream write will create it.
+ logger.warning(
+ "Pre-stream SQLite write failed for %s: %s",
+ session_id,
+ pre_write_exc,
+ exc_info=True,
+ )
+
+ # Run the agent in its own task so it can be cancelled independently.
+ # Suppress duplicate emitter lifecycle callbacks only during the
+ # run_with_mcp execution window (pre/post_tool_call hooks fire there).
+ set_suppress_emitter_tool_events(True)
+ active_agent_task = asyncio.create_task(
+ agent.run_with_mcp(
+ message_to_send, **run_kwargs
+ )
+ )
+
+ # ===== CONCURRENT MESSAGE PROCESSING FIX =====
+ # Instead of awaiting the agent task here (which blocks the receive loop),
+ # we use asyncio.wait() to handle BOTH the agent task AND incoming messages.
+ # This allows permission_response messages to be processed while the agent runs.
+
+ result = None
+ agent_completed = False
+ # Deferred message for switch_session/create_session during streaming
+ _deferred_msg: dict | None = None
+
+ while not agent_completed:
+ # Create a task for the next message (or wait for agent)
+ receive_task = asyncio.create_task(
+ websocket.receive_json()
+ )
+
+ # Wait for either the agent to complete OR a new message to arrive
+ done, pending = await asyncio.wait(
+ {active_agent_task, receive_task},
+ return_when=asyncio.FIRST_COMPLETED,
+ )
+
+ # Check if agent completed
+ if active_agent_task in done:
+ try:
+ result = await active_agent_task
+ logger.debug(
+ f"run_with_mcp completed, result type: {type(result)}"
+ )
+ agent_completed = True
+ active_agent_task = None
+ except asyncio.CancelledError:
+ logger.debug(
+ "run_with_mcp task was cancelled by user"
+ )
+ agent_error = "cancelled"
+ result = None
+ agent_completed = True
+ active_agent_task = None
+ except Exception as e:
+ logger.error(
+ "Agent task error: %s",
+ e,
+ exc_info=True,
+ )
+ logger.debug(
+ "[WS:%s] agent task exception captured: type=%s repr=%r",
+ session_id,
+ type(e).__name__,
+ e,
+ )
+ agent_error = e
+ result = None
+ agent_completed = True
+ active_agent_task = None
+
+ # Cancel the receive task if it's still pending
+ if receive_task in pending:
+ receive_task.cancel()
+ try:
+ await receive_task
+ except asyncio.CancelledError:
+ pass
+
+ # Check if a new message arrived
+ elif receive_task in done:
+ try:
+ new_msg = await receive_task
+
+ # Advisory validation — log but never reject
+ try:
+ _parsed_inner = _ClientMessageAdapter.validate_python(
+ new_msg
+ )
+ except ValidationError as _val_err:
+ logger.warning(
+ "Client message failed validation: %s",
+ str(_val_err),
+ extra={
+ "type": new_msg.get("type")
+ if isinstance(new_msg, dict)
+ else "unknown"
+ },
+ )
+
+ # Handle permission_response immediately
+ if (
+ new_msg.get("type")
+ == "permission_response"
+ ):
+ from code_puppy.api.permissions import (
+ handle_permission_response,
+ )
+
+ request_id = new_msg.get(
+ "request_id"
+ )
+ approved = new_msg.get(
+ "approved", False
+ )
+
+ if request_id:
+ handled = (
+ handle_permission_response(
+ request_id, approved
+ )
+ )
+ if handled:
+ logger.debug(
+ f"[Permission] ✅ Handled response: {request_id} = {approved}"
+ )
+ else:
+ logger.warning(
+ f"[Permission] ❌ Unknown request: {request_id}"
+ )
+ else:
+ logger.error(
+ "[WebSocket] ❌ No request_id in permission_response!"
+ )
+
+ # Handle cancel request
+ elif new_msg.get("type") == "cancel":
+ logger.debug(
+ "Cancel request received during agent execution"
+ )
+ if (
+ active_agent_task
+ and not active_agent_task.done()
+ ):
+ active_agent_task.cancel()
+ agent_completed = (
+ True # Exit the loop
+ )
+
+ # Handle session switch during streaming
+ elif new_msg.get("type") in (
+ "switch_session",
+ "create_session",
+ ):
+ # User switched to a new chat while agent was running.
+ # The agent will finish in background and save to SQLite.
+ logger.debug(
+ "[WS:%s] Session switch during streaming — "
+ "agent continues in background, switching to: %s",
+ session_id,
+ new_msg.get("session_id"),
+ )
+
+ # Fire and forget — background task owns the agent result.
+ fire_and_track(
+ save_agent_result_in_background(
+ agent_task=active_agent_task,
+ session_id=session_id,
+ ctx=ctx,
+ agent=agent,
+ agent_name=agent_name,
+ model_name=model_name,
+ title=session_title,
+ working_directory=session_working_directory,
+ pinned=session_pinned,
+ label="switch",
+ )
+ )
+
+ _deferred_msg = new_msg
+ active_agent_task = None # disown — background task now owns it
+ agent_completed = (
+ True # exit inner loop
+ )
+
+ # For other message types, log and ignore (or queue for later)
+ else:
+ logger.warning(
+ f"[WebSocket] Received {new_msg.get('type')} message while agent running - ignoring"
+ )
+
+ except asyncio.CancelledError:
+ # Receive was cancelled, continue
+ pass
+ except WebSocketDisconnect:
+ # WebSocket gone — let agent finish and save to SQLite
+ logger.debug(
+ "[WS:%s] Disconnect during streaming — agent continues in background",
+ session_id,
+ )
+
+ # Fire and forget — background task owns the agent result.
+ fire_and_track(
+ save_agent_result_in_background(
+ agent_task=active_agent_task,
+ session_id=session_id,
+ ctx=ctx,
+ agent=agent,
+ agent_name=agent_name,
+ model_name=model_name,
+ title=session_title,
+ working_directory=session_working_directory,
+ pinned=session_pinned,
+ label="disconnect",
+ )
+ )
+ active_agent_task = None
+ agent_completed = True
+ except RuntimeError as e:
+ if "disconnect" in str(e).lower():
+ # Starlette raises RuntimeError when calling receive after disconnect
+ logger.debug(
+ "[WS:%s] WebSocket already disconnected: %s — agent continues in background",
+ session_id,
+ e,
+ )
+
+ # Fire and forget — background task owns the agent result.
+ fire_and_track(
+ save_agent_result_in_background(
+ agent_task=active_agent_task,
+ session_id=session_id,
+ ctx=ctx,
+ agent=agent,
+ agent_name=agent_name,
+ model_name=model_name,
+ title=session_title,
+ working_directory=session_working_directory,
+ pinned=session_pinned,
+ label="runtime",
+ )
+ )
+ active_agent_task = None
+ agent_completed = True
+ else:
+ logger.error(
+ f"RuntimeError processing message during agent execution: {e}"
+ )
+ except Exception as e:
+ logger.error(
+ f"Error processing message during agent execution: {e}"
+ )
+
+ # ===== END CONCURRENT MESSAGE PROCESSING FIX =====
+
+ finally:
+ # End suppression scope once run_with_mcp window ends,
+ # including completion/cancel/background handoff paths.
+ set_suppress_emitter_tool_events(False)
+ # Clear session context for prompt generation
+ clear_session_working_directory()
+
+ finally:
+ # Stop the drain task
+ if drain_task:
+ stop_draining.set()
+ try:
+ await asyncio.wait_for(drain_task, timeout=2.0)
+ except asyncio.TimeoutError:
+ drain_task.cancel()
+ try:
+ await drain_task
+ except asyncio.CancelledError:
+ pass
+
+ # Unsubscribe from emitter
+ if event_queue:
+ unsubscribe(event_queue)
+ logger.debug("Unsubscribed from frontend emitter")
+
+ # Process any deferred switch_session that arrived during streaming
+ if _deferred_msg is not None:
+ msg = _deferred_msg
+ _deferred_msg = None
+ # Re-dispatch to outer loop by continuing with msg set
+ # The outer while True loop will handle switch_session/create_session
+ continue
+
+ # If agent errored, send error to GUI and skip success path
+ if agent_error == "cancelled":
+ await send_typed(
+ ServerCancelled(
+ session_id=session_id,
+ )
+ )
+ continue
+ elif agent_error is not None:
+ logger.debug(
+ "[WS:%s] agent_error -> sending frame(s) to client. type=%s",
+ session_id,
+ type(agent_error).__name__,
+ )
+ # If streaming was already in progress, send stream_end first so
+ # the frontend exits its streaming state before the error frame.
+ # Without this handshake the UI hangs indefinitely.
+ for frame in build_error_response_frames(
+ agent_error, collected_text, session_id
+ ):
+ await safe_send_json(frame)
+ continue
+
+ # Safety net: if the agent task completed without raising but returned
+ # no result and produced no streamed text, treat it as an error.
+ has_nonempty_stream = any(
+ (chunk or "").strip() for chunk in collected_text
+ )
+ logger.debug(
+ "[WS:%s] post-run: result_is_none=%s collected_chunks=%d has_nonempty_stream=%s",
+ session_id,
+ result is None,
+ len(collected_text),
+ has_nonempty_stream,
+ )
+
+ if result is None and not has_nonempty_stream:
+ logger.warning(
+ "[WS:%s] Agent task completed with result=None and no streamed text; treating as error.",
+ session_id,
+ )
+ parsed_error = parse_api_error(
+ RuntimeError(
+ "Agent run failed (no result returned). Check server logs for the underlying exception."
+ )
+ )
+ await send_typed(
+ ServerError(
+ error=parsed_error["user_message"],
+ error_type=parsed_error["error_type"],
+ technical_details=parsed_error[
+ "technical_details"
+ ],
+ action_required=parsed_error.get(
+ "action_required"
+ ),
+ session_id=session_id,
+ )
+ )
+ continue
+
+ # Extract final response text
+ response_text = ""
+
+ # Priority 1: Use collected text from streaming events
+ if has_nonempty_stream:
+ response_text = "".join(collected_text)
+ logger.debug(
+ f"Using collected streaming text ({len(response_text)} chars)"
+ )
+ # Priority 2: Extract from result.output (AgentRunResult) or result.data (legacy)
+ elif result:
+ if hasattr(result, "output"):
+ response_text = (
+ str(result.output)
+ if result.output
+ else "(Empty response)"
+ )
+ logger.debug(
+ f"Using result.output ({len(response_text)} chars)"
+ )
+ elif hasattr(result, "data"):
+ response_text = (
+ str(result.data)
+ if result.data
+ else "(Empty response)"
+ )
+ logger.debug(
+ f"Using result.data ({len(response_text)} chars)"
+ )
+ # Priority 3: Get last assistant message from history
+ elif agent:
+ messages = agent.get_message_history()
+
+ for msg in reversed(messages):
+ if hasattr(msg, "role") and msg.role == "assistant":
+ if hasattr(msg, "content"):
+ response_text = str(msg.content)
+ logger.debug(
+ f"Using message history ({len(response_text)} chars)"
+ )
+
+ break
+
+ if not response_text:
+ response_text = "Agent returned no response"
+
+ # Extract token usage from result if available
+ tokens_used = None
+ if result:
+ # Try to get usage from result
+ if hasattr(result, "usage"):
+ usage = result.usage
+ if usage:
+ tokens_used = {
+ "input_tokens": getattr(
+ usage, "input_tokens", None
+ )
+ or getattr(usage, "prompt_tokens", None),
+ "output_tokens": getattr(
+ usage, "output_tokens", None
+ )
+ or getattr(
+ usage, "completion_tokens", None
+ ),
+ "total_tokens": getattr(
+ usage, "total_tokens", None
+ ),
+ }
+ # Also try _usage or other common patterns
+ elif hasattr(result, "_usage"):
+ usage = result._usage
+ if usage:
+ tokens_used = {
+ "input_tokens": getattr(
+ usage, "input_tokens", None
+ )
+ or getattr(usage, "prompt_tokens", None),
+ "output_tokens": getattr(
+ usage, "output_tokens", None
+ )
+ or getattr(
+ usage, "completion_tokens", None
+ ),
+ "total_tokens": getattr(
+ usage, "total_tokens", None
+ ),
+ }
+
+ # If we couldn't get usage from result, estimate from message history
+ if not tokens_used and agent:
+ try:
+ history = agent.get_message_history()
+ total_estimated = sum(
+ agent.estimate_tokens_for_message(msg)
+ for msg in history
+ )
+ tokens_used = {
+ "total_tokens": total_estimated,
+ "estimated": True,
+ }
+ except Exception:
+ pass
+
+ # Only send legacy 'response' if B1 streaming wasn't used
+ # B1 streaming already sent the content via assistant_message_end
+ logger.warning(
+ "[WebSocket] b1_streaming_used=%s before response/extraction",
+ b1_streaming_used,
+ )
+ if not b1_streaming_used:
+ # For non-streaming models (like Gemini), extract and send thinking content first
+ thinking_text = ""
+ if agent:
+ try:
+ history = agent.get_message_history()
+ logger.debug(
+ f"[Thinking Debug] Checking history for thinking parts, {len(history) if history else 0} messages"
+ )
+ if history:
+ # Look for ThinkingPart in the last ModelResponse
+ for i, msg in enumerate(reversed(history)):
+ msg_type = type(msg).__name__
+ logger.debug(
+ f"[Thinking Debug] Message {i}: type={msg_type}, has_parts={hasattr(msg, 'parts')}"
+ )
+ if "Response" in msg_type and hasattr(
+ msg, "parts"
+ ):
+ logger.debug(
+ f"[Thinking Debug] Found Response with {len(msg.parts)} parts"
+ )
+ for j, part in enumerate(msg.parts):
+ part_type = type(part).__name__
+ part_content_preview = (
+ str(
+ getattr(
+ part, "content", ""
+ )
+ )[:100]
+ if hasattr(part, "content")
+ else "N/A"
+ )
+ logger.debug(
+ f"[Thinking Debug] Part {j}: type={part_type}, content_preview={part_content_preview}"
+ )
+ if (
+ "Thinking" in part_type
+ and hasattr(part, "content")
+ ):
+ thinking_text = part.content
+ logger.debug(
+ f"[Thinking Debug] Found thinking content: {len(thinking_text)} chars"
+ )
+ break
+ if thinking_text:
+ break
+ except Exception as e:
+ logger.warning(
+ f"Could not extract thinking content: {e}"
+ )
+ import traceback
+
+ logger.warning(traceback.format_exc())
+
+ if not thinking_text:
+ logger.debug(
+ "[Thinking Debug] No thinking content found in message history"
+ )
+
+ # Send thinking content as B1 message if available
+ if thinking_text:
+ import time as time_module
+
+ thinking_message_id = f"thinking-{session_id}-{int(time_module.time() * 1000)}"
+ await send_typed(
+ ServerAssistantMessageStart(
+ message_id=thinking_message_id,
+ part_type="thinking",
+ part_index=0,
+ timestamp=time_module.time(),
+ session_id=session_id,
+ agent_name=agent.name
+ if agent
+ else "code-puppy",
+ model_name=agent.get_model_name()
+ if agent
+ else "unknown",
+ )
+ )
+ _delta = (
+ ServerAssistantMessageDelta.model_construct(
+ type="assistant_message_delta",
+ message_id=thinking_message_id,
+ content=thinking_text,
+ part_index=0,
+ session_id=session_id,
+ agent_name=agent.name
+ if agent
+ else "code-puppy",
+ model_name=agent.get_model_name()
+ if agent
+ else "unknown",
+ )
+ )
+ await safe_send_json(
+ _delta.model_dump(exclude_none=True)
+ )
+ await send_typed(
+ ServerAssistantMessageEnd(
+ message_id=thinking_message_id,
+ part_index=0,
+ full_content=thinking_text,
+ timestamp=time_module.time(),
+ session_id=session_id,
+ agent_name=agent.name
+ if agent
+ else "code-puppy",
+ model_name=agent.get_model_name()
+ if agent
+ else "unknown",
+ )
+ )
+ logger.debug(
+ f"Sent thinking content ({len(thinking_text)} chars) for non-streaming model"
+ )
+
+ # Send the main response
+ await send_typed(
+ ServerResponse(
+ content=response_text,
+ done=True,
+ session_id=session_id,
+ agent_name=agent.name
+ if agent
+ else "code-puppy",
+ model_name=agent.get_model_name()
+ if agent
+ else "unknown",
+ tokens=tokens_used,
+ )
+ )
+ else:
+ # B1 streaming: extract real tool results BEFORE stream_end
+ # so the frontend session store is still alive when they arrive.
+ _pre_sent_tool_ids: set = set()
+ logger.warning(
+ "[WebSocket] B1 Pre-stream_end extraction: result=%s, has_all_messages=%s",
+ type(result).__name__ if result else None,
+ hasattr(result, "all_messages")
+ if result
+ else False,
+ )
+ if result and hasattr(result, "all_messages"):
+ try:
+ import time as _time_pre
+
+ from pydantic_ai.messages import (
+ ToolReturn,
+ ToolReturnPart,
+ )
+
+ _pre_msgs = list(result.all_messages())
+ for _pre_msg in _pre_msgs:
+ if not hasattr(_pre_msg, "parts"):
+ continue
+ for _pre_part in _pre_msg.parts:
+ if not isinstance(
+ _pre_part,
+ (ToolReturnPart, ToolReturn),
+ ):
+ continue
+ _pre_tool_name = getattr(
+ _pre_part, "tool_name", "unknown"
+ )
+ _pre_raw_tool_id = getattr(
+ _pre_part, "tool_call_id", None
+ )
+ _pre_tool_id = (
+ tool_id_aliases.get(
+ _pre_raw_tool_id,
+ _pre_raw_tool_id,
+ )
+ if _pre_raw_tool_id
+ else "unknown"
+ )
+ _pre_result = getattr(
+ _pre_part, "content", None
+ )
+ # Serialize complex objects
+ if _pre_result and not isinstance(
+ _pre_result,
+ (
+ str,
+ dict,
+ list,
+ int,
+ float,
+ bool,
+ type(None),
+ ),
+ ):
+ try:
+ if hasattr(
+ _pre_result, "model_dump"
+ ):
+ _pre_result = (
+ _pre_result.model_dump()
+ )
+ elif hasattr(
+ _pre_result, "dict"
+ ):
+ _pre_result = (
+ _pre_result.dict()
+ )
+ elif hasattr(
+ _pre_result, "__dict__"
+ ):
+ _pre_result = (
+ _pre_result.__dict__
+ )
+ else:
+ _pre_result = str(
+ _pre_result
+ )
+ except Exception:
+ _pre_result = str(_pre_result)
+ logger.warning(
+ "[WebSocket] Pre-stream_end tool result: %s (id: %s), content_preview=%s",
+ _pre_tool_name,
+ _pre_tool_id,
+ str(_pre_result)[:100]
+ if _pre_result
+ else None,
+ )
+ _pre_group_id = tool_group_ids.get(
+ _pre_tool_id
+ )
+
+ await send_typed_tool_lifecycle(
+ ServerToolResult(
+ tool_id=_pre_tool_id,
+ tool_name=_pre_tool_name,
+ result=_pre_result,
+ success=True,
+ duration_ms=0,
+ timestamp=_time_pre.time(),
+ session_id=session_id,
+ agent_name=agent.name
+ if agent
+ else "code-puppy",
+ model_name=agent.get_model_name()
+ if agent
+ else "unknown",
+ tool_group_id=_pre_group_id,
+ )
+ )
+ _pre_sent_tool_ids.add(_pre_tool_id)
+ except Exception as _pre_e:
+ logger.warning(
+ "Pre-stream_end tool result extraction failed: %s",
+ _pre_e,
+ )
+
+ # Send stream_end AFTER real tool results are delivered
+ await send_typed(
+ ServerStreamEnd(
+ success=True,
+ total_length=len(response_text),
+ agent_name=agent.name
+ if agent
+ else "code-puppy",
+ model_name=agent.get_model_name()
+ if agent
+ else "unknown",
+ tokens=tokens_used,
+ session_id=session_id,
+ )
+ )
+
+ # Save session after each response
+ # Track tool IDs already sent before stream_end to skip duplicates later
+ if "_pre_sent_tool_ids" not in dir():
+ _pre_sent_tool_ids: set = set()
+ _save_history_snapshot = [] # Snapshot of history before await points
+ try:
+ # CRITICAL: Update message history from result to include final response
+ # The pydantic-ai result.all_messages() contains the complete conversation
+ # including the final assistant response that may not be in agent's history yet
+ if result and hasattr(result, "all_messages"):
+ try:
+ all_msgs = list(result.all_messages())
+ if all_msgs:
+ agent.set_message_history(all_msgs)
+ _save_history_snapshot = list(
+ agent.get_message_history()
+ ) # Snapshot before any awaits can corrupt shared global state
+ logger.debug(
+ f"Updated message history from result.all_messages(): {len(all_msgs)} messages"
+ )
+
+ # Extract and send tool results from message history
+ try:
+ import time as _time
+
+ from pydantic_ai.messages import (
+ ToolReturn,
+ ToolReturnPart,
+ )
+
+ for msg in all_msgs:
+ if hasattr(msg, "parts"):
+ for part in msg.parts:
+ if isinstance(
+ part,
+ (
+ ToolReturnPart,
+ ToolReturn,
+ ),
+ ):
+ tool_name = getattr(
+ part,
+ "tool_name",
+ "unknown",
+ )
+ tool_call_id = getattr(
+ part,
+ "tool_call_id",
+ "unknown",
+ )
+ # Serialize the result
+ result_data = getattr(
+ part, "content", None
+ )
+ if (
+ result_data
+ and not isinstance(
+ result_data,
+ (
+ str,
+ dict,
+ list,
+ int,
+ float,
+ bool,
+ type(None),
+ ),
+ )
+ ):
+ try:
+ if hasattr(
+ result_data,
+ "model_dump",
+ ):
+ result_data = result_data.model_dump()
+ elif hasattr(
+ result_data,
+ "dict",
+ ):
+ result_data = result_data.dict()
+ elif hasattr(
+ result_data,
+ "__dict__",
+ ):
+ result_data = result_data.__dict__
+ else:
+ result_data = str(
+ result_data
+ )
+ except Exception:
+ result_data = str(
+ result_data
+ )
+
+ # Log for shell commands
+ if (
+ tool_name
+ == "agent_run_shell_command"
+ ):
+ stdout_val = (
+ result_data.get(
+ "stdout", "N/A"
+ )
+ if isinstance(
+ result_data,
+ dict,
+ )
+ else "not dict"
+ )
+ logger.debug(
+ "Extracted shell result: id=%s, stdout=%s",
+ tool_call_id,
+ stdout_val,
+ )
+
+ # Skip tool IDs already sent before stream_end
+ if (
+ tool_call_id
+ in _pre_sent_tool_ids
+ ):
+ logger.debug(
+ "[WebSocket] Skipping duplicate tool result (pre-sent): %s",
+ tool_call_id,
+ )
+ continue
+ logger.info(
+ f"[WebSocket] Sending extracted tool result for {tool_name} (id: {tool_call_id})"
+ )
+ _post_group_id = (
+ tool_group_ids.get(
+ tool_call_id
+ )
+ )
+
+ await send_typed(
+ ServerToolResult(
+ tool_id=tool_call_id,
+ tool_name=tool_name,
+ result=result_data,
+ success=True,
+ duration_ms=0,
+ timestamp=_time.time(),
+ session_id=session_id,
+ agent_name=agent.name
+ if agent
+ else "code-puppy",
+ model_name=agent.get_model_name()
+ if agent
+ else "unknown",
+ tool_group_id=_post_group_id,
+ )
+ )
+ except Exception as e:
+ logger.warning(
+ f"Could not extract tool results from messages: {e}"
+ )
+
+ except Exception as e:
+ logger.warning(
+ f"Could not update history from result.all_messages(): {e}"
+ )
+
+ history = (
+ _save_history_snapshot
+ if _save_history_snapshot
+ else agent.get_message_history()
+ ) # Use pre-await snapshot to avoid race condition
+ if history:
+ # Regenerate title if it's empty or still the default "untitled-session"
+ if (
+ not session_title
+ or session_title == "untitled-session"
+ ):
+ session_title = generate_heuristic_title(
+ history
+ )
+
+ # Session name is just the ID (WS_session_timestamp format)
+ # Title is stored only in the meta file, not in the filename
+ session_name = session_id
+
+ # Wrap each message with metadata for complete session information.
+ # Chain `or` fallbacks: agent.name can be "" before the agent
+ # object is fully configured, so we cascade to context defaults.
+ agent_name_meta = (
+ (agent.name if agent else "")
+ or ctx.agent_name
+ or "code-puppy"
+ )
+ model_name_meta = (
+ (agent.get_model_name() if agent else "")
+ or ctx.model_name
+ or "unknown"
+ )
+
+ def _extract_message_timestamp(
+ raw_msg: Any, default_ts: str
+ ) -> str:
+ """Best-effort extraction of an existing timestamp for a message.
+
+ This is important for WebSocket sessions where history may
+ already contain older messages. We don't want to overwrite
+ their original timestamps every time we auto-save.
+
+ Precedence:
+ 1. If the message is a dict with a numeric epoch 'timestamp',
+ convert to ISO.
+ 2. If the message is a dict with an ISO-ish 'timestamp' str,
+ reuse it as-is.
+ 3. If the message is a dict with 'ts', reuse it.
+ 4. If the message object has a 'timestamp' attribute, try that.
+ 5. Fall back to the provided default_ts ("now" for new messages).
+ """
+ # Dict-based histories (e.g. CLI format or older WS formats)
+ if isinstance(raw_msg, dict):
+ ts_val = raw_msg.get("timestamp")
+
+ # Epoch seconds
+ if isinstance(ts_val, (int, float)):
+ try:
+ return (
+ datetime.datetime.fromtimestamp(
+ ts_val
+ ).isoformat()
+ )
+ except Exception:
+ pass
+
+ # Already an ISO string
+ if isinstance(ts_val, str) and ts_val:
+ return ts_val
+
+ # Our enhanced WS wrapper sometimes uses 'ts'
+ ts_field = raw_msg.get("ts")
+ if isinstance(ts_field, str) and ts_field:
+ return ts_field
+
+ # Pydantic / custom objects that carry a timestamp attribute
+ try:
+ attr_ts = getattr(
+ raw_msg, "timestamp", None
+ )
+ if isinstance(attr_ts, (int, float)):
+ return datetime.datetime.fromtimestamp(
+ attr_ts
+ ).isoformat()
+ if isinstance(attr_ts, str) and attr_ts:
+ return attr_ts
+ except Exception:
+ pass
+
+ # Fallback: use provided default
+ return default_ts
+
+ # Create enhanced history: list of dicts with message + metadata
+ # Each entry: {'msg': , 'agent': str, 'model': str, 'ts': str}
+ enhanced_history = []
+ for idx, msg in enumerate(history):
+ # Check if already wrapped (for idempotency). If the wrapper
+ # already has a 'ts', leave it untouched so older sessions
+ # keep their original per-message timestamps.
+ if (
+ isinstance(msg, dict)
+ and "msg" in msg
+ and "agent" in msg
+ ):
+ enhanced_history.append(msg)
+ else:
+ # Use existing timestamp if present; otherwise compute a default now()
+ current_timestamp = (
+ datetime.datetime.now().isoformat()
+ )
+ msg_ts = _extract_message_timestamp(
+ msg, current_timestamp
+ )
+ wrapper = {
+ "msg": msg,
+ "agent": agent_name_meta,
+ "model": model_name_meta,
+ "ts": msg_ts,
+ }
+
+ # Add clean_content and attachments to the user message we just processed.
+ # clean_content is only needed when attachments were injected into content
+ # (file blocks like --- File: auth.ts ---). Without attachments, content
+ # is already the user's words (FE strips [Session Context:] as fallback).
+ is_user_message_just_processed = (
+ idx == len(history) - 2
+ and len(history) >= 2
+ and attachment_metadata # only needed when file content was injected
+ )
+
+ if is_user_message_just_processed:
+ wrapper["clean_content"] = (
+ original_user_message
+ )
+ wrapper["attachments"] = (
+ attachment_metadata
+ )
+ logger.debug(
+ "Added UI metadata to user message: %d attachment(s), "
+ "clean_content length: %d",
+ len(attachment_metadata),
+ len(original_user_message),
+ )
+
+ enhanced_history.append(wrapper)
+
+ message_count = len(enhanced_history)
+ total_tokens = 0
+ try:
+ for item in enhanced_history:
+ msg_obj = (
+ item["msg"]
+ if isinstance(item, dict)
+ and "msg" in item
+ else item
+ )
+ total_tokens += (
+ agent.estimate_tokens_for_message(
+ msg_obj
+ )
+ )
+ except Exception:
+ total_tokens = 0
+
+ # Write to SQLite for FE read path
+ try:
+ import datetime as _dt_mod
+
+ _now_iso = _dt_mod.datetime.now(
+ _dt_mod.timezone.utc
+ ).isoformat()
+ await write_turn_to_sqlite(
+ session_id=session_id,
+ enhanced_history=enhanced_history,
+ title=session_title,
+ working_directory=session_working_directory,
+ pinned=session_pinned,
+ agent_name=agent_name,
+ model_name=model_name,
+ total_tokens=total_tokens,
+ updated_at=_now_iso,
+ created_at=ctx.created_at.isoformat(),
+ ctx=ctx,
+ )
+ except Exception as _db_exc:
+ logger.debug(
+ "SQLite turn write skipped (DB not available): %s",
+ _db_exc,
+ )
+
+ # Send session metadata update to client
+ await websocket.send_json(
+ {
+ "type": "session_meta",
+ "session_id": session_id,
+ "session_name": session_name,
+ "total_tokens": total_tokens,
+ "message_count": message_count,
+ "title": session_title,
+ "working_directory": session_working_directory,
+ "agent_name": agent_name,
+ "model_name": model_name,
+ }
+ )
+
+ # Broadcast session update to session monitoring clients
+ session_update_data = {
+ "session_id": session_id,
+ "session_name": session_name,
+ "title": session_title,
+ "working_directory": session_working_directory,
+ "timestamp": datetime.datetime.now().isoformat(),
+ "message_count": message_count,
+ "total_tokens": total_tokens,
+ "auto_saved": True,
+ "pickle_path": "",
+ "metadata_path": "",
+ "action": "created"
+ if message_count == 1
+ else "updated",
+ }
+ await connection_manager.broadcast_session_update(
+ session_update_data
+ )
+ except Exception as save_err:
+ logger.warning(
+ f"Failed to save WebSocket session: {save_err}"
+ )
+
+ # Send final status to signal completion
+ await send_typed(
+ ServerStatus(
+ status="done",
+ session_id=session_id,
+ agent_name=agent.name if agent else "code-puppy",
+ model_name=agent.get_model_name()
+ if agent
+ else "unknown",
+ )
+ )
+
+ except Exception as e:
+ logger.error(
+ f"Error processing message: {e}", exc_info=True
+ )
+
+ # Parse error and send user-friendly message
+ parsed_error = parse_api_error(e)
+ _err_msg = ServerError(
+ error=parsed_error["user_message"],
+ error_type=parsed_error["error_type"],
+ technical_details=parsed_error["technical_details"],
+ action_required=parsed_error.get("action_required"),
+ session_id=session_id,
+ )
+ error_payload = _err_msg.model_dump(exclude_none=True)
+ await persist_error_payload(error_payload)
+ await send_typed(_err_msg)
+
+ except WebSocketDisconnect:
+ break
+ except RuntimeError as e:
+ # Handle disconnect-related RuntimeErrors (starlette raises these)
+ if (
+ "disconnect" in str(e).lower()
+ or "websocket.close" in str(e).lower()
+ ):
+ logger.debug("WebSocket disconnected (RuntimeError): %s", e)
+ break
+ # Re-raise other RuntimeErrors to be handled as generic exceptions
+ raise
+ except Exception as e:
+ logger.error("Chat WebSocket error: %s", e, exc_info=True)
+ # Don't try to send error if websocket is already closed
+ if ws_closed:
+ break
+ try:
+ _err_msg = ServerError(
+ error=str(e),
+ error_type="unknown",
+ technical_details=str(e),
+ session_id=session_id,
+ )
+ error_payload = _err_msg.model_dump(exclude_none=True)
+ await persist_error_payload(error_payload)
+ await send_typed(_err_msg)
+ except Exception:
+ break
+
+ except WebSocketDisconnect:
+ ws_closed = True
+ logger.debug("Chat WebSocket client disconnected")
+ except Exception as e:
+ logger.error("Chat WebSocket error: %s", e, exc_info=True)
+ finally:
+ logger.debug("Chat session %s ended", session_id)
+
+ # --- SESSION ISOLATION CLEANUP ---
+ # Save and tear down session-scoped resources
+ try:
+ await session_manager.save_session(session_id)
+ except Exception:
+ logger.debug("Failed to save session on disconnect", exc_info=True)
+
+ # Don't destroy immediately - mark as inactive for 15-min retention
+ await session_manager.mark_session_inactive(session_id)
+ cleanup_session_process_tracking()
+
+ try:
+ bus = get_message_bus()
+ bus.set_session_context(None)
+ except Exception:
+ pass
diff --git a/code_puppy/api/ws/legacy/connection_manager.py b/code_puppy/api/ws/legacy/connection_manager.py
new file mode 100644
index 000000000..eec49804d
--- /dev/null
+++ b/code_puppy/api/ws/legacy/connection_manager.py
@@ -0,0 +1,55 @@
+"""WebSocket connection manager for broadcasting session updates.
+
+This is a singleton that tracks connected WebSocket clients monitoring
+session state changes.
+"""
+
+import logging
+
+from fastapi import WebSocket
+
+logger = logging.getLogger(__name__)
+
+
+class WebSocketConnectionManager:
+ """Manages WebSocket connections for broadcasting session updates."""
+
+ def __init__(self):
+ self.session_connections: list[WebSocket] = []
+
+ async def connect_session_client(self, websocket: WebSocket):
+ """Connect a session monitoring client."""
+ self.session_connections.append(websocket)
+ logger.debug(
+ f"Session client connected. Total: {len(self.session_connections)}"
+ )
+
+ def disconnect_session_client(self, websocket: WebSocket):
+ """Disconnect a session monitoring client."""
+ if websocket in self.session_connections:
+ self.session_connections.remove(websocket)
+ logger.debug(
+ f"Session client disconnected. Total: {len(self.session_connections)}"
+ )
+
+ async def broadcast_session_update(self, session_data: dict):
+ """Broadcast session update to all connected session clients."""
+ if not self.session_connections:
+ return
+
+ connections = self.session_connections.copy()
+ disconnected = []
+
+ for conn in connections:
+ try:
+ await conn.send_json({"type": "session_update", "data": session_data})
+ except Exception as e:
+ logger.warning("Failed to send session update: %s", e)
+ disconnected.append(conn)
+
+ for conn in disconnected:
+ self.disconnect_session_client(conn)
+
+
+# Global singleton instance
+connection_manager = WebSocketConnectionManager()
diff --git a/code_puppy/api/ws/legacy/events_handler.py b/code_puppy/api/ws/legacy/events_handler.py
new file mode 100644
index 000000000..b9bf647ec
--- /dev/null
+++ b/code_puppy/api/ws/legacy/events_handler.py
@@ -0,0 +1,53 @@
+"""WebSocket endpoint for server-sent events streaming."""
+
+import asyncio
+import logging
+
+from fastapi import FastAPI, WebSocket, WebSocketDisconnect
+
+logger = logging.getLogger(__name__)
+
+
+def register_events_endpoint(app: FastAPI) -> None:
+ """Register the /ws/events WebSocket endpoint."""
+
+ @app.websocket("/ws/events")
+ async def websocket_events(
+ websocket: WebSocket, session_id: str | None = None
+ ) -> None:
+ """Stream real-time events to connected clients.
+
+ Query Parameters:
+ session_id: Optional session ID. If provided, only events for that
+ session are streamed.
+ """
+ await websocket.accept()
+ logger.debug("Events WebSocket client connected (session_id=%s)", session_id)
+
+ from code_puppy.plugins.frontend_emitter.emitter import (
+ get_recent_events,
+ subscribe,
+ unsubscribe,
+ )
+
+ event_queue = subscribe(session_id=session_id)
+
+ try:
+ for event in get_recent_events(session_id=session_id):
+ await websocket.send_json(event)
+
+ while True:
+ try:
+ event = await asyncio.wait_for(event_queue.get(), timeout=30.0)
+ await websocket.send_json(event)
+ except asyncio.TimeoutError:
+ try:
+ await websocket.send_json({"type": "ping"})
+ except Exception:
+ break
+ except WebSocketDisconnect:
+ logger.debug("Events WebSocket client disconnected")
+ except Exception as e:
+ logger.error("Events WebSocket error: %s", e)
+ finally:
+ unsubscribe(event_queue)
diff --git a/code_puppy/api/ws/legacy/health_handler.py b/code_puppy/api/ws/legacy/health_handler.py
new file mode 100644
index 000000000..832f45e95
--- /dev/null
+++ b/code_puppy/api/ws/legacy/health_handler.py
@@ -0,0 +1,22 @@
+"""WebSocket endpoint for health checks."""
+
+import logging
+
+from fastapi import FastAPI, WebSocket, WebSocketDisconnect
+
+logger = logging.getLogger(__name__)
+
+
+def register_health_endpoint(app: FastAPI) -> None:
+ """Register the /ws/health WebSocket endpoint."""
+
+ @app.websocket("/ws/health")
+ async def websocket_health(websocket: WebSocket) -> None:
+ """Simple WebSocket health check - echoes messages back."""
+ await websocket.accept()
+ try:
+ while True:
+ data = await websocket.receive_text()
+ await websocket.send_text(f"echo: {data}")
+ except WebSocketDisconnect:
+ pass
diff --git a/code_puppy/api/ws/legacy/runtime/__init__.py b/code_puppy/api/ws/legacy/runtime/__init__.py
new file mode 100644
index 000000000..750f74d72
--- /dev/null
+++ b/code_puppy/api/ws/legacy/runtime/__init__.py
@@ -0,0 +1,14 @@
+"""Runtime scaffolding for multi-session chat.
+
+These modules are intentionally lightweight and are not yet wired into
+`/ws/chat` or the planned `/ws/chat_mux` endpoint.
+
+They provide an async-friendly session runtime object (cancellation, active
+streaming task handle, etc.) and a manager that keeps runtimes keyed by
+session_id.
+"""
+
+from .session_runtime import SessionRuntime
+from .session_runtime_manager import SessionRuntimeManager
+
+__all__ = ["SessionRuntime", "SessionRuntimeManager"]
diff --git a/code_puppy/api/ws/legacy/runtime/session_runtime.py b/code_puppy/api/ws/legacy/runtime/session_runtime.py
new file mode 100644
index 000000000..a6fda2741
--- /dev/null
+++ b/code_puppy/api/ws/legacy/runtime/session_runtime.py
@@ -0,0 +1,68 @@
+from __future__ import annotations
+
+import asyncio
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+from typing import Any
+
+from code_puppy.api.session_context import SessionContext
+
+
+@dataclass(slots=True)
+class SessionRuntime:
+ """Async runtime state for a single session.
+
+ This complements :class:`~code_puppy.api.session_context.SessionContext`.
+
+ SessionContext stores durable-ish session state (agent, history, metadata).
+ SessionRuntime stores ephemeral runtime state (active stream task,
+ cancellation token/event, etc.).
+
+ NOTE: This is scaffolding for chat mux and is not yet integrated into
+ websocket handlers.
+ """
+
+ session_id: str
+ ctx: SessionContext
+
+ created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+ last_used_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
+
+ # When set, any active streaming loop should stop ASAP.
+ cancel_event: asyncio.Event = field(default_factory=asyncio.Event)
+
+ # Track at most one active streaming task at a time for now.
+ _active_task: asyncio.Task[Any] | None = None
+
+ # Serialise runtime-level mutations.
+ _lock: asyncio.Lock = field(default_factory=asyncio.Lock, repr=False)
+
+ async def set_active_task(self, task: asyncio.Task[Any] | None) -> None:
+ """Set (or clear) the active streaming task for this session."""
+ async with self._lock:
+ self._active_task = task
+ self.last_used_at = datetime.now(timezone.utc)
+
+ async def get_active_task(self) -> asyncio.Task[Any] | None:
+ async with self._lock:
+ return self._active_task
+
+ async def cancel(self) -> None:
+ """Cancel the active streaming task (if any) and set cancel_event."""
+ self.cancel_event.set()
+ async with self._lock:
+ task = self._active_task
+
+ if task and not task.done():
+ task.cancel()
+
+ async def reset_cancel(self) -> None:
+ """Clear cancel state for the next turn."""
+ # asyncio.Event has no clear() in older Python; but 3.11+ has.
+ # This project supports 3.11+, so use clear().
+ self.cancel_event.clear()
+
+ async def touch(self) -> None:
+ """Update last_used_at."""
+ async with self._lock:
+ self.last_used_at = datetime.now(timezone.utc)
diff --git a/code_puppy/api/ws/legacy/runtime/session_runtime_manager.py b/code_puppy/api/ws/legacy/runtime/session_runtime_manager.py
new file mode 100644
index 000000000..82a8252d3
--- /dev/null
+++ b/code_puppy/api/ws/legacy/runtime/session_runtime_manager.py
@@ -0,0 +1,150 @@
+from __future__ import annotations
+
+import asyncio
+import logging
+from collections.abc import Callable
+from dataclasses import dataclass
+from datetime import datetime, timedelta, timezone
+
+from code_puppy.api.session_context import SessionContext, session_manager
+
+from .session_runtime import SessionRuntime
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True, slots=True)
+class RuntimeCleanupResult:
+ removed_session_ids: tuple[str, ...]
+
+
+class SessionRuntimeManager:
+ """Registry for :class:`~code_puppy.api.ws.runtime.SessionRuntime`.
+
+ This is designed for the multiplexed chat websocket, where the
+ server keeps multiple sessions "hot" at once.
+ """
+
+ def __init__(self, now: Callable[[], datetime] | None = None) -> None:
+ self._now = now or (lambda: datetime.now(timezone.utc))
+ self._runtimes: dict[str, SessionRuntime] = {}
+ self._lock = asyncio.Lock()
+
+ async def get_runtime(self, session_id: str) -> SessionRuntime | None:
+ async with self._lock:
+ return self._runtimes.get(session_id)
+
+ async def ensure_runtime(
+ self,
+ session_id: str,
+ *,
+ agent_name: str = "code-puppy",
+ model_name: str | None = None,
+ working_directory: str = "",
+ load_if_exists: bool = True,
+ ) -> SessionRuntime:
+ """Get or create a runtime for *session_id*.
+
+ This bridges to the existing :mod:`code_puppy.api.session_context` layer:
+ - If an in-memory SessionContext exists, we reuse it.
+ - Else, we optionally load from disk via SessionManager.load_session.
+ - Else, we create a new SessionContext.
+
+ Args:
+ load_if_exists: If True, attempt to load persisted session state.
+
+ Returns:
+ SessionRuntime instance.
+ """
+ async with self._lock:
+ existing = self._runtimes.get(session_id)
+ if existing is not None:
+ existing.last_used_at = self._now()
+ return existing
+
+ ctx = await session_manager.get_session(session_id)
+ if ctx is None and load_if_exists:
+ try:
+ ctx = await session_manager.load_session(session_id)
+ except Exception:
+ logger.debug(
+ "Failed to load session %s from disk", session_id, exc_info=True
+ )
+ ctx = None
+
+ if ctx is None:
+ ctx = await session_manager.create_session(
+ session_id,
+ agent_name=agent_name,
+ model_name=model_name,
+ working_directory=working_directory,
+ )
+
+ rt = SessionRuntime(session_id=session_id, ctx=ctx)
+ rt.last_used_at = self._now()
+ self._runtimes[session_id] = rt
+ return rt
+
+ async def cancel_session(self, session_id: str) -> bool:
+ """Cancel the active task for *session_id*.
+
+ Returns:
+ True if a runtime existed and cancellation was requested.
+ """
+ rt = await self.get_runtime(session_id)
+ if rt is None:
+ return False
+ await rt.cancel()
+ return True
+
+ async def remove_runtime(self, session_id: str) -> bool:
+ """Remove runtime from the manager.
+
+ This does not destroy the underlying SessionContext (that remains in the
+ existing SessionManager registry).
+ """
+ async with self._lock:
+ removed = self._runtimes.pop(session_id, None)
+ return removed is not None
+
+ async def cleanup_idle(self, *, max_idle: timedelta) -> RuntimeCleanupResult:
+ """Remove runtimes that have been idle longer than *max_idle*."""
+ now = self._now()
+ removed: list[str] = []
+
+ async with self._lock:
+ for session_id, rt in list(self._runtimes.items()):
+ if now - rt.last_used_at > max_idle:
+ removed.append(session_id)
+ self._runtimes.pop(session_id, None)
+
+ if removed:
+ logger.info("Cleaned up %d idle runtimes", len(removed))
+
+ return RuntimeCleanupResult(removed_session_ids=tuple(removed))
+
+ async def get_session_context(self, session_id: str) -> SessionContext | None:
+ rt = await self.get_runtime(session_id)
+ return rt.ctx if rt else None
+
+ async def active_task_count(self) -> int:
+ """Count runtimes with active tasks (for debugging/tests)."""
+ async with self._lock:
+ rts = list(self._runtimes.values())
+
+ count = 0
+ for rt in rts:
+ task = await rt.get_active_task()
+ if task is not None and not task.done():
+ count += 1
+ return count
+
+
+_manager_instance: SessionRuntimeManager | None = None
+
+
+def get_runtime_manager() -> SessionRuntimeManager:
+ global _manager_instance
+ if _manager_instance is None:
+ _manager_instance = SessionRuntimeManager()
+ return _manager_instance
diff --git a/code_puppy/api/ws/legacy/schemas.py b/code_puppy/api/ws/legacy/schemas.py
new file mode 100644
index 000000000..05e56528d
--- /dev/null
+++ b/code_puppy/api/ws/legacy/schemas.py
@@ -0,0 +1,603 @@
+"""WebSocket protocol schemas — single source of truth for the CP wire protocol.
+
+Every client→server and server→client message is defined here as a Pydantic
+model with ``Literal`` type discriminators so the framework can parse raw JSON
+into the correct model automatically via ``ClientMessage`` / ``ServerMessage``
+discriminated unions.
+
+Serialisation convention:
+ ``.model_dump(exclude_none=True)`` — optional fields that are ``None``
+ are omitted from the wire representation to keep payloads lean.
+
+All field names and types are derived from the *actual* ``send_json`` /
+``safe_send_json`` calls in ``chat_handler.py`` and ``permissions.py``.
+"""
+
+from __future__ import annotations
+
+from enum import Enum
+from typing import Annotated, Any, Dict, List, Literal, Optional, Union
+
+from pydantic import BaseModel, Discriminator, Tag
+
+# ── Protocol version ────────────────────────────────────────────────────────
+PROTOCOL_VERSION = "1.0.0"
+
+
+# ── Enums ────────────────────────────────────────────────────────────────────
+
+
+class ClientMessageType(str, Enum):
+ """All ``type`` values a client may send over the WebSocket."""
+
+ MESSAGE = "message"
+ SWITCH_AGENT = "switch_agent"
+ SWITCH_MODEL = "switch_model"
+ SWITCH_SESSION = "switch_session"
+ SET_WORKING_DIRECTORY = "set_working_directory"
+ UPDATE_SESSION_META = "update_session_meta"
+ GET_CONFIG = "get_config"
+ SET_CONFIG = "set_config"
+ COMMAND = "command"
+ CANCEL = "cancel"
+ PERMISSION_RESPONSE = "permission_response"
+
+
+class ServerMessageType(str, Enum):
+ """All ``type`` values the server may send over the WebSocket."""
+
+ SYSTEM = "system"
+ SESSION_RESTORED = "session_restored"
+ SESSION_SWITCHED = "session_switched"
+ WORKING_DIRECTORY_CHANGED = "working_directory_changed"
+ SESSION_META_UPDATED = "session_meta_updated"
+ CONFIG_VALUE = "config_value"
+ COMMAND_RESULT = "command_result"
+ STATUS = "status"
+ USER_MESSAGE = "user_message"
+ ASSISTANT_MESSAGE_START = "assistant_message_start"
+ ASSISTANT_MESSAGE_DELTA = "assistant_message_delta"
+ ASSISTANT_MESSAGE_END = "assistant_message_end"
+ TOOL_CALL = "tool_call"
+ TOOL_RESULT = "tool_result"
+ TOOL_RETURN = "tool_return"
+ AGENT_INVOKED = "agent_invoked"
+ RESPONSE = "response"
+ STREAM_END = "stream_end"
+ ERROR = "error"
+ PERMISSION_REQUEST = "permission_request"
+ CANCELLED = "cancelled"
+
+
+class PartType(str, Enum):
+ """Streaming part types used in ``assistant_message_start``."""
+
+ TEXT = "text"
+ THINKING = "thinking"
+ TOOL_CALL = "tool_call"
+
+
+class ErrorType(str, Enum):
+ """Error categories returned by ``parse_api_error`` / ``error_parser``."""
+
+ RATE_LIMIT = "rate_limit"
+ SERVER_ERROR = "server_error"
+ AUTH_ERROR = "auth_error"
+ QUOTA_EXCEEDED = "quota_exceeded"
+ CONTENT_BLOCKED = "content_blocked"
+ TOOL_HISTORY_ERROR = "tool_history_error"
+ NETWORK_ERROR = "network_error"
+ MODEL_NOT_FOUND = "model_not_found"
+ CLAUDE_TEMPERATURE_ERROR = "claude_temperature_error"
+ UNKNOWN = "unknown"
+ UNKNOWN_ERROR = "unknown_error"
+
+
+# ── Base ─────────────────────────────────────────────────────────────────────
+
+
+class _BaseMessage(BaseModel):
+ """Shared config for all protocol messages."""
+
+ model_config = {"extra": "allow"}
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# CLIENT → SERVER (11 message types)
+# ═══════════════════════════════════════════════════════════════════════════════
+
+
+class ClientMessageMessage(_BaseMessage):
+ """User chat message.
+
+ Sent when the user types a message in the chat input.
+ ``content`` is the message text. Optional ``model`` overrides the
+ session model for this single turn. ``model_settings`` passes
+ per-request knobs (e.g. reasoning_effort). ``attachments`` is a
+ list of file paths to include.
+ """
+
+ type: Literal["message"] = "message"
+ content: str
+ model: Optional[str] = None
+ model_settings: Optional[Dict[str, Any]] = None
+ attachments: Optional[List[str]] = None
+
+
+class ClientSwitchAgent(_BaseMessage):
+ """Request to switch the active agent.
+
+ ``agent_name`` is the target agent identifier (e.g. ``"code-puppy"``).
+ """
+
+ type: Literal["switch_agent"] = "switch_agent"
+ agent_name: str
+
+
+class ClientSwitchModel(_BaseMessage):
+ """Request to switch the active model.
+
+ The server accepts either ``model_name`` or ``model`` for backwards
+ compatibility — see ``msg.get("model_name") or msg.get("model")``.
+ """
+
+ type: Literal["switch_model"] = "switch_model"
+ model_name: Optional[str] = None
+ model: Optional[str] = None
+
+
+class ClientSwitchSession(_BaseMessage):
+ """Request to switch to a different chat session.
+
+ ``session_id`` is the target session identifier.
+ """
+
+ type: Literal["switch_session"] = "switch_session"
+ session_id: str
+
+
+class ClientSetWorkingDirectory(_BaseMessage):
+ """Set the working directory for the current session.
+
+ ``directory`` is an absolute filesystem path.
+ """
+
+ type: Literal["set_working_directory"] = "set_working_directory"
+ directory: str
+
+
+class ClientUpdateSessionMeta(_BaseMessage):
+ """Update session metadata (title, pinned state).
+
+ Both fields are optional — only supplied fields are updated.
+ """
+
+ type: Literal["update_session_meta"] = "update_session_meta"
+ title: Optional[str] = None
+ pinned: Optional[bool] = None
+
+
+class ClientGetConfig(_BaseMessage):
+ """Read a configuration value by key."""
+
+ type: Literal["get_config"] = "get_config"
+ key: str
+
+
+class ClientSetConfig(_BaseMessage):
+ """Write a configuration value."""
+
+ type: Literal["set_config"] = "set_config"
+ key: str
+ value: Any
+
+
+class ClientCommand(_BaseMessage):
+ """Execute a slash command (e.g. ``/help``)."""
+
+ type: Literal["command"] = "command"
+ command: str
+
+
+class ClientCancel(_BaseMessage):
+ """Cancel / interrupt the current streaming response or agent run."""
+
+ type: Literal["cancel"] = "cancel"
+
+
+class ClientPermissionResponse(_BaseMessage):
+ """User's response to a permission request.
+
+ ``approved`` indicates whether the user granted the permission.
+ ``remember`` is reserved for future "always allow" support.
+ """
+
+ type: Literal["permission_response"] = "permission_response"
+ request_id: str
+ approved: bool
+ remember: Optional[bool] = None
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# SERVER → CLIENT (21 message types)
+# ═══════════════════════════════════════════════════════════════════════════════
+
+
+class ServerSystem(_BaseMessage):
+ """System / informational message.
+
+ Sent on initial connection (welcome), agent/model switches, and
+ replayed directory banners on session restore.
+ """
+
+ type: Literal["system"] = "system"
+ content: str
+ session_id: str
+ agent_name: Optional[str] = None
+ model_name: Optional[str] = None
+ resumed: Optional[bool] = None
+ protocol_version: Optional[str] = None
+
+
+class ServerSessionRestored(_BaseMessage):
+ """Confirmation that an existing session was restored from storage.
+
+ ``message_count`` reflects how many messages were loaded into the
+ agent's history. ``ui_metadata`` carries replayed UI-only records.
+ """
+
+ type: Literal["session_restored"] = "session_restored"
+ session_id: str
+ message_count: int
+ title: Optional[str] = None
+ ui_metadata: Optional[List[Any]] = None
+
+
+class ServerSessionSwitched(_BaseMessage):
+ """Confirmation that the active session was switched.
+
+ ``created`` is ``True`` when the target session did not exist and
+ was freshly created.
+ """
+
+ type: Literal["session_switched"] = "session_switched"
+ session_id: str
+ message_count: int
+ title: Optional[str] = None
+ working_directory: Optional[str] = None
+ created: bool
+ agent_name: Optional[str] = None
+ model_name: Optional[str] = None
+
+
+class ServerWorkingDirectoryChanged(_BaseMessage):
+ """Result of a ``set_working_directory`` request.
+
+ ``success`` is ``False`` when the directory does not exist.
+ ``unchanged`` is ``True`` when the directory matches the current one.
+ """
+
+ type: Literal["working_directory_changed"] = "working_directory_changed"
+ directory: str
+ success: bool
+ session_id: str
+ unchanged: Optional[bool] = None
+ error: Optional[str] = None
+
+
+class ServerSessionMetaUpdated(_BaseMessage):
+ """Acknowledgement that session metadata was updated."""
+
+ type: Literal["session_meta_updated"] = "session_meta_updated"
+ session_id: str
+ pinned: Optional[bool] = None
+ title: Optional[str] = None
+
+
+class ServerConfigValue(_BaseMessage):
+ """Response to ``get_config`` or ``set_config``.
+
+ ``success`` is present only on ``set_config`` responses.
+ """
+
+ type: Literal["config_value"] = "config_value"
+ key: str
+ value: Any
+ session_id: str
+ success: Optional[bool] = None
+
+
+class ServerCommandResult(_BaseMessage):
+ """Result of a slash command execution.
+
+ ``output`` contains rendered text (e.g. help output).
+ ``error`` is present when the command raised an exception.
+ """
+
+ type: Literal["command_result"] = "command_result"
+ command: str
+ success: bool
+ session_id: str
+ output: Optional[str] = None
+ messages: Optional[List[Any]] = None
+ result: Optional[str] = None
+ error: Optional[str] = None
+
+
+class ServerStatus(_BaseMessage):
+ """Transient status update (e.g. ``"cancelled"``, ``"thinking"``).
+
+ Sent in response to a ``cancel`` request or to indicate agent
+ activity (e.g. "thinking" with current agent/model context).
+ """
+
+ type: Literal["status"] = "status"
+ status: str
+ session_id: str
+ agent_name: Optional[str] = None
+ model_name: Optional[str] = None
+
+
+class ServerUserMessage(_BaseMessage):
+ """Echo of the user's chat message back to the client.
+
+ Sent immediately after receiving a ``message`` from the client so
+ the UI can render it before the assistant starts responding.
+ """
+
+ type: Literal["user_message"] = "user_message"
+ content: str
+ session_id: str
+
+
+class ServerAssistantMessageStart(_BaseMessage):
+ """Marks the beginning of a streaming assistant message part.
+
+ ``part_type`` is ``"text"`` or ``"thinking"``.
+ ``tool_name`` is set when the text part follows a tool call.
+ """
+
+ type: Literal["assistant_message_start"] = "assistant_message_start"
+ message_id: str
+ part_type: str
+ part_index: int
+ timestamp: float
+ session_id: str
+ agent_name: Optional[str] = None
+ model_name: Optional[str] = None
+ tool_name: Optional[str] = None
+
+
+class ServerAssistantMessageDelta(_BaseMessage):
+ """A streaming content chunk for an in-progress assistant message."""
+
+ type: Literal["assistant_message_delta"] = "assistant_message_delta"
+ message_id: str
+ content: str
+ part_index: int
+ session_id: str
+ agent_name: Optional[str] = None
+ model_name: Optional[str] = None
+ tool_name: Optional[str] = None
+
+
+class ServerAssistantMessageEnd(_BaseMessage):
+ """Marks the end of a streaming assistant message part.
+
+ ``full_content`` contains the complete accumulated text for the part.
+ """
+
+ type: Literal["assistant_message_end"] = "assistant_message_end"
+ message_id: str
+ part_index: int
+ full_content: str
+ timestamp: float
+ session_id: str
+ agent_name: Optional[str] = None
+ model_name: Optional[str] = None
+ tool_name: Optional[str] = None
+
+
+class ServerToolCall(_BaseMessage):
+ """Notification that a tool was invoked.
+
+ ``args`` contains the parsed tool arguments. ``tool_id`` correlates
+ with a later ``tool_result`` bearing the same ID. ``tool_group_id``
+ groups parallel tool calls in the same execution batch.
+ """
+
+ type: Literal["tool_call"] = "tool_call"
+ tool_id: str
+ tool_name: str
+ args: Any # dict after JSON parse, but may be raw str during streaming
+ timestamp: float
+ session_id: str
+ agent_name: Optional[str] = None
+ model_name: Optional[str] = None
+ tool_group_id: Optional[str] = None # Groups tools in same execution batch
+
+
+class ServerToolResult(_BaseMessage):
+ """Result of a tool execution.
+
+ ``tool_id`` matches the originating ``tool_call``. ``result`` is the
+ tool's return value (may be ``dict``, ``str``, or a status sentinel).
+ ``tool_group_id`` matches the originating ``tool_call`` group.
+ """
+
+ type: Literal["tool_result"] = "tool_result"
+ tool_name: str
+ result: Any
+ success: bool
+ duration_ms: float
+ timestamp: float
+ session_id: str
+ tool_id: Optional[str] = None
+ agent_name: Optional[str] = None
+ model_name: Optional[str] = None
+ tool_group_id: Optional[str] = None # Matches originating tool_call group
+
+
+class ServerToolReturn(_BaseMessage):
+ """Internal tool-return part tracked during streaming.
+
+ This message type represents a ``ToolReturnPart`` observed in the
+ streaming pipeline. It is stored internally for correlation but may
+ not always be sent directly to the client.
+ """
+
+ type: Literal["tool_return"] = "tool_return"
+ tool_call_id: Optional[str] = None
+ content: Optional[Any] = None
+ session_id: Optional[str] = None
+
+
+class ServerAgentInvoked(_BaseMessage):
+ """Notification that a sub-agent was invoked.
+
+ ``prompt_preview`` is a truncated preview of the prompt sent to the
+ sub-agent.
+ """
+
+ type: Literal["agent_invoked"] = "agent_invoked"
+ agent_name: str
+ prompt_preview: Optional[str] = None
+ timestamp: float
+ session_id: str
+
+
+class TokenUsage(BaseModel):
+ """Token usage statistics attached to ``response`` and ``stream_end``."""
+
+ input_tokens: Optional[int] = None
+ output_tokens: Optional[int] = None
+ total_tokens: Optional[int] = None
+ estimated: Optional[bool] = None
+
+
+class ServerResponse(_BaseMessage):
+ """Complete (non-streaming) response.
+
+ Sent when B1 streaming was *not* used (e.g. non-streaming models).
+ ``tokens`` carries optional token usage info.
+ """
+
+ type: Literal["response"] = "response"
+ content: str
+ done: bool
+ session_id: str
+ agent_name: Optional[str] = None
+ model_name: Optional[str] = None
+ tokens: Optional[Union[TokenUsage, Dict[str, Any]]] = None
+
+
+class ServerStreamEnd(_BaseMessage):
+ """End-of-stream marker for B1 streaming responses.
+
+ ``success`` is ``False`` when the stream ended due to an error.
+ ``total_length`` is the character count of the accumulated response.
+ """
+
+ type: Literal["stream_end"] = "stream_end"
+ success: bool
+ session_id: str
+ total_length: Optional[int] = None
+ agent_name: Optional[str] = None
+ model_name: Optional[str] = None
+ tokens: Optional[Union[TokenUsage, Dict[str, Any]]] = None
+
+
+class ServerError(_BaseMessage):
+ """Error message.
+
+ ``error_type`` classifies the error for frontend handling (e.g.
+ ``"rate_limit"``, ``"auth_error"``). ``action_required`` hints
+ that user intervention may be needed.
+ """
+
+ type: Literal["error"] = "error"
+ error: str
+ session_id: str
+ error_type: Optional[str] = None
+ technical_details: Optional[str] = None
+ action_required: Optional[bool] = None
+
+
+class ServerPermissionRequest(_BaseMessage):
+ """Asks the user to approve or deny a potentially dangerous operation.
+
+ Sent by the permission system before executing shell commands or
+ other privileged operations. The frontend should present an
+ approve/deny dialog and reply with ``permission_response``.
+ """
+
+ type: Literal["permission_request"] = "permission_request"
+ request_id: str
+ permission_type: str
+ title: str
+ description: str
+ details: Dict[str, Any]
+ session_id: str
+ timeout_seconds: int
+
+
+class ServerCancelled(_BaseMessage):
+ """Confirmation that the current agent run was cancelled.
+
+ Sent when the agent task is interrupted after a ``cancel`` request.
+ """
+
+ type: Literal["cancelled"] = "cancelled"
+ session_id: str
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# Discriminated unions
+# ═══════════════════════════════════════════════════════════════════════════════
+
+
+ClientMessage = Annotated[
+ Union[
+ Annotated[ClientMessageMessage, Tag("message")],
+ Annotated[ClientSwitchAgent, Tag("switch_agent")],
+ Annotated[ClientSwitchModel, Tag("switch_model")],
+ Annotated[ClientSwitchSession, Tag("switch_session")],
+ Annotated[ClientSetWorkingDirectory, Tag("set_working_directory")],
+ Annotated[ClientUpdateSessionMeta, Tag("update_session_meta")],
+ Annotated[ClientGetConfig, Tag("get_config")],
+ Annotated[ClientSetConfig, Tag("set_config")],
+ Annotated[ClientCommand, Tag("command")],
+ Annotated[ClientCancel, Tag("cancel")],
+ Annotated[ClientPermissionResponse, Tag("permission_response")],
+ ],
+ Discriminator("type"),
+]
+"""Discriminated union of all client→server message types."""
+
+ServerMessage = Annotated[
+ Union[
+ Annotated[ServerSystem, Tag("system")],
+ Annotated[ServerSessionRestored, Tag("session_restored")],
+ Annotated[ServerSessionSwitched, Tag("session_switched")],
+ Annotated[ServerWorkingDirectoryChanged, Tag("working_directory_changed")],
+ Annotated[ServerSessionMetaUpdated, Tag("session_meta_updated")],
+ Annotated[ServerConfigValue, Tag("config_value")],
+ Annotated[ServerCommandResult, Tag("command_result")],
+ Annotated[ServerStatus, Tag("status")],
+ Annotated[ServerUserMessage, Tag("user_message")],
+ Annotated[ServerAssistantMessageStart, Tag("assistant_message_start")],
+ Annotated[ServerAssistantMessageDelta, Tag("assistant_message_delta")],
+ Annotated[ServerAssistantMessageEnd, Tag("assistant_message_end")],
+ Annotated[ServerToolCall, Tag("tool_call")],
+ Annotated[ServerToolResult, Tag("tool_result")],
+ Annotated[ServerToolReturn, Tag("tool_return")],
+ Annotated[ServerAgentInvoked, Tag("agent_invoked")],
+ Annotated[ServerResponse, Tag("response")],
+ Annotated[ServerStreamEnd, Tag("stream_end")],
+ Annotated[ServerError, Tag("error")],
+ Annotated[ServerPermissionRequest, Tag("permission_request")],
+ Annotated[ServerCancelled, Tag("cancelled")],
+ ],
+ Discriminator("type"),
+]
+"""Discriminated union of all server→client message types."""
diff --git a/code_puppy/api/ws/legacy/sessions_handler.py b/code_puppy/api/ws/legacy/sessions_handler.py
new file mode 100644
index 000000000..f0719648c
--- /dev/null
+++ b/code_puppy/api/ws/legacy/sessions_handler.py
@@ -0,0 +1,69 @@
+"""WebSocket endpoint for real-time session monitoring."""
+
+import asyncio
+import logging
+
+from fastapi import FastAPI, WebSocket, WebSocketDisconnect
+
+from code_puppy.api.ws.connection_manager import connection_manager
+
+logger = logging.getLogger(__name__)
+
+
+def register_sessions_endpoint(app: FastAPI) -> None:
+ """Register the /ws/sessions WebSocket endpoint."""
+
+ @app.websocket("/ws/sessions")
+ async def websocket_sessions(websocket: WebSocket) -> None:
+ """WebSocket endpoint for real-time session updates.
+
+ Clients can connect to this endpoint to receive real-time notifications
+ when WebSocket sessions are created, updated, or deleted.
+
+ Messages sent to clients:
+ - {"type": "session_update", "data": WSSessionInfo}
+ - {"type": "ping"} (keepalive)
+ """
+ await websocket.accept()
+ logger.debug("Sessions monitoring WebSocket client connected")
+
+ try:
+ await connection_manager.connect_session_client(websocket)
+
+ # Send initial session list
+ try:
+ from code_puppy.api.routers.ws_sessions import list_ws_sessions
+
+ current_sessions = await list_ws_sessions()
+ await websocket.send_json(
+ {
+ "type": "initial_sessions",
+ "data": [session.model_dump() for session in current_sessions],
+ }
+ )
+ except Exception as e:
+ logger.error("Failed to send initial sessions: %s", e)
+ await websocket.send_json(
+ {"type": "error", "error": f"Failed to load sessions: {str(e)}"}
+ )
+
+ # Keep connection alive
+ while True:
+ try:
+ message = await asyncio.wait_for(
+ websocket.receive_json(), timeout=30.0
+ )
+ if message.get("type") == "ping":
+ await websocket.send_json({"type": "pong"})
+ except asyncio.TimeoutError:
+ try:
+ await websocket.send_json({"type": "ping"})
+ except Exception:
+ break
+
+ except WebSocketDisconnect:
+ logger.debug("Sessions monitoring WebSocket client disconnected")
+ except Exception as e:
+ logger.error("Sessions WebSocket error: %s", e)
+ finally:
+ connection_manager.disconnect_session_client(websocket)
diff --git a/docs/migration/puppy-desk-gate1-investigation-summary.md b/docs/migration/puppy-desk-gate1-investigation-summary.md
new file mode 100644
index 000000000..90fe9dd5b
--- /dev/null
+++ b/docs/migration/puppy-desk-gate1-investigation-summary.md
@@ -0,0 +1,426 @@
+# Puppy Desk Migration — Gate 1 Investigation Summary
+
+Saved to avoid losing detailed context during summarization.
+
+## User Requirements and Constraints
+
+- Migrate divergent source branch `feature/puppy-desk` from `../../../../dev/puppy/code-puppy` into the current repo.
+- Preserve features under `code_puppy/api/ws`, including:
+ - WebSocket session handling
+ - event streaming
+ - tool args/results
+ - streaming deltas
+ - strict message ordering
+ - permission handling
+ - CWD setup
+ - active agent/model handling
+ - local config updates
+ - async independent session management
+ - multiple parallel sessions
+- Do **not touch `main`**.
+- Work from a new branch off `main` / migration branch, not directly on `main`.
+- Keep legacy code until user explicitly approves cleanup.
+- Duplicate current legacy into a `legacy/` namespace before behavioral changes.
+- Use the same WS route only after full parity tests pass.
+- No application code changes until explicit approval.
+- Prioritize safety over speed.
+- Plan for migration, GUGI testing, and cleanup.
+- Modularize long files, especially very large WS handlers.
+
+## Planning History
+
+- Generated migration task plans:
+ - `.maestro/tasks-20260601-093100-bzoj.json`, pack `zg6w`.
+ - `.maestro/tasks-20260601-093500-giep.json`, pack `kzh5`.
+- User approved Gate 1: read-only inventory/migration matrix.
+- Attempted BD issue creation from the plan; tool reported success but created `0` BD issues.
+- Current continuation plan generated as:
+ - `.maestro/tasks-20260601-094423-9on6.json`, pack `vtyn`.
+
+## Local Repo / Worktree Observations
+
+- Working area root: `/Users/s0m0961/Documents/dev/personal/puppy/code_puppy.worktrees`.
+- Candidate directories found:
+ - `external-ws-replica-test/`
+ - `h37o-input-notifier/`
+ - `maestro-checkpoints-20260526/`
+ - `puppy-desk-migration/`
+- Important branches/statuses:
+ - `/code_puppy.worktrees/external-ws-replica-test`
+ - branch: `test/external-ws-migration-replica`
+ - clean
+ - no `code_puppy/api` directory
+ - `/code_puppy.worktrees/puppy-desk-migration`
+ - branch: `feature/puppy-desk-migration`
+ - modified files:
+ - `code_puppy/config.py`
+ - `code_puppy/plugins/frontend_emitter/emitter.py`
+ - `code_puppy/session_storage.py`
+ - `code_puppy/tools/command_runner.py`
+ - `pyproject.toml`
+ - `uv.lock`
+ - source repo `/Users/s0m0961/Documents/dev/puppy/code-puppy`
+ - branch: `feature/puppy-desk`
+ - modified files:
+ - `code_puppy/model_utils.py`
+ - `uv.lock`
+ - untracked:
+ - `scripts/run_ws_streaming_api_server.sh`
+
+## Branch Diffs and Scope
+
+- Source `feature/puppy-desk` vs `main` has 53 relevant changed/added files around:
+ - `code_puppy/api/**`
+ - `code_puppy/plugins/frontend_emitter/**`
+ - `code_puppy/config.py`
+ - `code_puppy/session_storage.py`
+ - `code_puppy/tools/command_runner.py`
+ - `pyproject.toml`
+ - `uv.lock`
+- Diff stat source vs main:
+ - ~53 files changed
+ - ~15.5k insertions
+ - ~547 deletions
+- Largest additions include:
+ - `api/ws/chat_handler.py`
+ - `api/db/queries.py`
+ - `api/templates/chat.html`
+ - API tests and WS tests
+ - config/session storage/tool runner/frontend emitter changes
+- `puppy-desk-migration` vs `main` appears to already contain a port of many API files.
+- `external-ws-replica-test` appears useful for newer frontend emitter work but does not contain the API/WS stack.
+
+## API / WS Stack Inventory
+
+Source `feature/puppy-desk` includes full API/WS stack:
+
+- `code_puppy/api/app.py`
+- `code_puppy/api/db/`
+- `code_puppy/api/permission_plugin.py`
+- `code_puppy/api/permissions.py`
+- routers
+- `code_puppy/api/session_cache.py`
+- `code_puppy/api/session_context.py`
+- templates
+- tests
+- `code_puppy/api/websocket.py`
+- `code_puppy/api/ws/` handlers, schemas, runtime, tests
+
+`puppy-desk-migration` has a similar stack and extra tests:
+
+- `test_config_router_schema.py`
+- `test_session_model_compat.py`
+
+`external-ws-replica-test` has no `code_puppy/api` directory but has newer frontend emitter files.
+
+## Key Feature Search Findings
+
+Searched for terms including:
+
+- `assistant_message_delta`
+- `ServerToolResult`
+- `permission_response`
+- `switch_session`
+- `switch_model`
+- `set_cwd`
+- `working_directory`
+- `b1_streaming_used`
+- `stop_draining`
+- `current_tool_group_id`
+
+Findings:
+
+- `schemas.py` defines protocol messages for:
+ - `switch_model`
+ - `switch_session`
+ - `set_working_directory`
+ - `permission_response`
+ - `assistant_message_delta`
+ - `tool_result`
+- Tests assert `ServerToolResult(...)` includes `tool_group_id`.
+- `background_save.py` handles `working_directory`.
+- `session_runtime_manager.py` stores `working_directory`.
+
+## Frontend Emitter Findings
+
+Source `feature/puppy-desk` frontend emitter:
+
+- Files:
+ - `__init__.py`
+ - `emitter.py`
+ - `register_callbacks.py`
+- No `session_context.py`.
+- Supports:
+ - session-scoped subscriptions
+ - global legacy subscriptions
+ - per-session recent event buffers
+ - `emit_event(event_type, data, session_id=None)`
+ - `subscribe(session_id=None)`
+ - `unsubscribe(queue)`
+ - `get_recent_events(session_id=None)`
+ - `get_subscriber_count()`
+ - `clear_recent_events()`
+- Global subscribers receive all events.
+- Session subscribers receive only matching session events.
+
+`puppy-desk-migration` and `external-ws-replica-test` frontend emitter:
+
+- Include `session_context.py`.
+- External replica has a newer design with:
+ - ContextVar fallback via `current_emitter_session_id`
+ - explicit `session_id` kwarg precedence
+ - wildcard and session-filtered subscribers
+ - `_Subscriber` dataclass records
+ - single recent events buffer
+ - legacy `_subscribers` compatibility
+ - double-delivery prevention.
+
+Frontend callback module findings:
+
+- Emits frontend events for:
+ - pre-tool call
+ - post-tool call
+ - stream events
+ - agent invocation
+- Reads WebSocket context from permission plugin to attach session ID.
+- Suppresses duplicate tool events during structured streaming.
+- Sanitizes tool args and stream data.
+- Serializes complex/Pydantic tool results.
+- Preserves structured list/dict args when payload is small enough for frontend rendering.
+
+## Key File Size / Hash Observations
+
+Source `feature/puppy-desk`:
+
+- `frontend_emitter/emitter.py`: ~6.2 KB
+- `register_callbacks.py`: ~12.1 KB
+- no `session_context.py`
+- `api/ws/chat_handler.py`: ~238 KB
+- `api/ws/schemas.py`: ~19.5 KB
+- `api/session_context.py`: ~24.2 KB
+- `api/db/queries.py`: ~40.8 KB
+
+`puppy-desk-migration`:
+
+- `frontend_emitter/emitter.py`: ~10 KB
+- `register_callbacks.py`: ~14.9 KB
+- `session_context.py`: ~2.1 KB
+- `api/ws/chat_handler.py`: ~235.8 KB
+- `api/ws/schemas.py`: same hash as source
+- `api/session_context.py`: ~24.9 KB
+- `api/db/queries.py`: same hash as source
+
+`external-ws-replica-test`:
+
+- emitter files similar to migration
+- no API/WS files
+
+## Source Git History Highlights
+
+Recent source commits touched:
+
+- daily backend logs
+- DB empty-row guard
+- `tool_group_id` consistency
+- JSON serialization of tool results
+- `RuntimeError` disconnect handling
+- tracking `tool_group_id` outside drain scope
+- pre-`stream_end` tool result extraction
+- duck-typed tool return extraction
+- emitter tool-event suppression scopes
+- unified tool IDs / duplicate suppression
+- schema migration of tool/terminal/permission messages
+- schema migration of config/command/streaming messages
+- advisory client validation
+- protocol model Literal defaults
+- `send_typed` helper migration
+
+## Key Source File Summaries
+
+### `code_puppy/api/websocket.py`
+
+- Thin endpoint registrar delegating to `code_puppy.api.ws` handlers:
+ - events
+ - chat
+ - terminal
+ - health
+ - sessions
+- Re-exports attachment builder and connection manager for backward compatibility.
+
+### `code_puppy/api/ws/schemas.py`
+
+- Defines protocol version `1.0.0`.
+- Defines typed Pydantic WebSocket protocol models.
+- Client message types include:
+ - `message`
+ - `switch_agent`
+ - `switch_model`
+ - `switch_session`
+ - `set_working_directory`
+ - `update_session_meta`
+ - `get_config`
+ - `set_config`
+ - `command`
+ - `cancel`
+ - `permission_response`
+- Server message types include:
+ - `system`
+ - `session_restored`
+ - `session_switched`
+ - `working_directory_changed`
+ - `session_meta_updated`
+ - `config_value`
+ - `command_result`
+ - `status`
+ - `user_message`
+ - `assistant_message_start`
+ - `assistant_message_delta`
+ - `assistant_message_end`
+ - `tool_call`
+ - `tool_result`
+ - `tool_return`
+ - `agent_invoked`
+ - `response`
+ - `stream_end`
+ - `error`
+ - `permission_request`
+ - `cancelled`
+
+### `api/ws/runtime/session_runtime_manager.py`
+
+- Async manager for per-session runtimes.
+- Supports runtime creation, lookup, cancellation, removal, idle cleanup, and active task count.
+- Designed for multiple hot sessions / multiplexed chat.
+
+### `api/ws/runtime/session_runtime.py`
+
+- Dataclass holding per-session runtime:
+ - active task
+ - cancel event
+ - lock
+ - created/last-used timestamps.
+
+### `api/permissions.py`
+
+- WebSocket permission request system.
+- Maintains global `permission_futures`.
+- `request_permission` sends typed permission request, waits up to 300s, honors YOLO mode, fails safe if no WS.
+- `handle_permission_response` resolves pending futures.
+
+### `api/permission_plugin.py`
+
+- Uses task-scoped `ContextVar` for WS permission context.
+- Has suppression flag for duplicate frontend emitter tool events.
+- `pre_tool_call_permission` asks frontend for approval and blocks denied tools.
+- Includes shell command permission handling.
+
+### `api/ws/chat_handler.py`
+
+Very large file, around 235–238 KB. Responsibilities include:
+
+- Session provisioning/restoration via SQLite and `session_manager`.
+- Initial config/CWD/system/session-restored messages.
+- WS message handlers for:
+ - switch agent
+ - switch model
+ - switch session
+ - set working directory
+ - update session metadata
+ - get/set config
+ - command
+ - cancel
+ - permission response
+ - user message
+- CWD persistence and unchanged-CWD messages.
+- Agent streaming plus concurrent frontend emitter draining.
+- Typed assistant/tool lifecycle messages:
+ - `assistant_message_start`
+ - `assistant_message_delta`
+ - `assistant_message_end`
+ - `tool_call`
+ - `tool_result`
+ - `stream_end`
+- Tracking of active parts, collected text, pending tool calls, tool ID aliases, and tool group IDs.
+- Handling deltas before starts by creating starts first.
+- Accumulating tool call args deltas.
+- Extracting full tool results before `stream_end`.
+- Accepting permission responses and cancel while streaming.
+- Supporting session switch/create while streaming through background save.
+
+Specific inspected area around lines 2350–2530 showed:
+
+- JSON parsing of tool args.
+- `tool_group_id` generation if missing.
+- Sending `ServerToolCall`.
+- Storing pending tool-call metadata.
+- Mapping raw tool call IDs to normalized IDs.
+- Cleaning active parts.
+- Handling WebSocket close during streaming.
+- Final-draining queued stream events.
+- Subscribing to frontend emitter by `session_id`.
+- Starting concurrent drain task.
+
+### `api/db/connection.py`
+
+- SQLite connection singleton using `aiosqlite`.
+- DB path from `PUPPY_DESK_DB`, otherwise `~/.puppy_desk/chat_messages.db`.
+- Backend can create/migrate schema if frontend has not.
+- Schema includes:
+ - `sessions`
+ - `messages`
+ - `tool_calls`
+ - `compaction_log`
+- Tracks schema versions/migrations up to v4.
+- Concurrency handled by `aiosqlite`, no threading lock.
+
+### `api/db/queries.py`
+
+- Large async query helper module for shared `chat_messages.db`.
+- Session operations include:
+ - `session_exists`
+ - `get_session_row`
+ - `get_session_metadata`
+ - `upsert_session`
+ - `update_session_stats`
+ - `update_session_working_directory`
+ - `update_session_meta_fields`
+ - `soft_delete_session`
+- Defines message sequencing/insertion helpers.
+- Uses `aiosqlite` and commits/rollbacks around writes.
+
+### `api/session_context.py`
+
+- Multi-session isolation layer.
+- Defines `SessionContext` with:
+ - per-session agent
+ - agent name
+ - model name
+ - working directory
+ - title/pinned metadata
+ - websocket reference
+ - saved per-agent histories
+ - compacted hashes
+ - lazy async operation lock
+- Defines validation for safe session IDs and known agent names.
+- Defines `SessionManager` for:
+ - create/get/destroy sessions
+ - switch agents
+ - preserve/restore history across agent switches
+ - carry session model across agent reloads.
+
+### `api/session_cache.py`
+
+- LRU cache for deserialized session data.
+- Uses async lock and thread pool for file-related work.
+- Features:
+ - max cached sessions
+ - TTL expiry
+ - file modification invalidation
+ - pre-serialized JSON cache
+ - stats for hits/misses/evictions/expirations.
+
+## Current State / Important Notes
+
+- No application code was modified during read-only Gate 1 investigation.
+- This file itself is a documentation checkpoint requested by the user.
+- Final migration matrix, options, risks, and recommendation still need to be completed.
diff --git a/docs/migration/puppy-desk-migration-plan-options.md b/docs/migration/puppy-desk-migration-plan-options.md
new file mode 100644
index 000000000..aff3de7ae
--- /dev/null
+++ b/docs/migration/puppy-desk-migration-plan-options.md
@@ -0,0 +1,825 @@
+# Puppy Desk Migration — Gate 1 Matrix, Options, and Approval Gates
+
+Status: **Gate 1 read-only planning + docs only**
+Base branch target: **main**
+Current working branch observed: `feature/puppy-desk-migration`
+Source branch/repo: `/Users/s0m0961/Documents/dev/puppy/code-puppy`, branch `feature/puppy-desk`
+Safety rule: **do not touch `main`; do not modify application code without explicit approval**
+
+Related checkpoint: `docs/migration/puppy-desk-gate1-investigation-summary.md`
+
+---
+
+## 1. Migration Objective
+
+Migrate the divergent `feature/puppy-desk` backend/API/WebSocket work into this repo while preserving all GUI-facing behavior needed by GUGI / Puppy Desk:
+
+- Same final WS route, but only after parity tests pass.
+- Legacy route/code preserved first in a `legacy/` namespace before behavior changes.
+- No feature loss around streaming, tool calls, permissions, CWD, sessions, config, DB persistence, or multiple sessions.
+- Long files must be modularized after parity is protected by tests.
+
+---
+
+## 2. High-Level Inventory Matrix
+
+| Area | Source `feature/puppy-desk` | Current migration branch | Gap / Decision | Risk |
+|---|---|---|---|---|
+| API package | Full `code_puppy/api/**` stack added | Similar/full stack already present | Verify current branch contains all intended API files and no accidental omissions | Medium |
+| WS route registrar | `api/websocket.py` delegates to `api.ws` handlers | Similar | Preserve legacy route until parity; same-route swap only after tests | High |
+| WS schemas | Typed Pydantic protocol v1.0.0; source and migration same hash | Same hash | Treat as contract; freeze with schema tests | High |
+| Huge chat handler | ~238 KB; central streaming/session/permission/CWD logic | ~230–236 KB; differs by small but important edits | Must first port/verify parity, then refactor behind tests | Very high |
+| Runtime manager | Per-session async runtime manager | Same structure | Keep; validate parallel sessions and cancellation | High |
+| Background save | Saves agent result/session metadata while switched away | Same file size/hash likely same | Validate session switch while streaming | High |
+| DB connection/schema | SQLite `~/.puppy_desk/chat_messages.db`, schema/migrations to v4 | Same/similar | Need DB migration tests with temp `PUPPY_DESK_DB` | High |
+| DB queries | Large async query helper; same hash in source/current | Same hash | Candidate to split after parity into session/message/tool modules | Medium |
+| Session context | Source supports isolated agent/model/CWD/history | Current has compatibility helpers for missing `set_session_model` | Keep migration helper if needed; validate model switching | High |
+| Permissions | WS permission futures + plugin context | Same/similar | Must validate approve/deny, timeout/fail-safe, YOLO | High |
+| Frontend emitter | Source has session-scoped queues + per-session recent buffers | Current/external has newer ContextVar design | Choose final emitter design carefully; behavior must prevent cross-session leakage | High |
+| Emitter callbacks | Source attaches session id via WS context and suppresses duplicate tool events | Current differs substantially | Need dedicated emitter parity tests | High |
+| Config router | Source expects `CONFIG_SCHEMA` | Current has fallback if schema absent | Decide whether to preserve full schema or fallback; GUI likely benefits from full schema | Medium |
+| Core config | Source contains larger `CONFIG_SCHEMA` and queue size default `10000`; current appears to remove schema and queue default returns `100` | Need explicit decision; queue size affects streaming reliability | High |
+| Command runner | Source imports callback directly | Current has ImportError fallback | Keep fallback if current architecture requires; validate terminal streaming callbacks | Medium |
+| Session storage | Same source/current | No action except integration validation | Low |
+| Model utils | Source/current differ, source has uncommitted changes | Need inspect before migration; could affect active model handling | Medium |
+| Dependencies | API requires FastAPI/aiosqlite/websockets/etc.; package files changed | `pyproject.toml` and `uv.lock` modified in current | Snyk required before testing if dependency changes are applied/retained | High |
+| Tests | Source has API/WS tests; current adds config/model compatibility tests | Combine and strengthen | Must add GUGI/parity scenarios before same-route swap | High |
+
+---
+
+## 3. Source vs Current Migration Differences Requiring Decisions
+
+### 3.1 `api/ws/chat_handler.py`
+
+Observed diff current-vs-source is small but semantically important:
+
+- Source appended a raw dict system message into agent in-memory history on CWD change.
+- Current migration branch replaces that with a warning comment: do **not** append raw dicts because runtime expects typed `ModelMessage` objects and raw dict injection can corrupt later turns / `result=None` behavior.
+
+Recommendation: **Keep the safer current behavior**, but verify the GUI still receives/persists directory banners through SQLite/system messages.
+
+Required tests:
+
+- Change CWD and verify WS `working_directory_changed` message.
+- Verify directory system banner persists and reloads.
+- Verify next agent turn after CWD change does not fail from corrupt history.
+
+### 3.2 Frontend emitter design
+
+Source design:
+
+- Explicit `session_id` on `emit_event`.
+- Global legacy subscribers receive all events.
+- Session subscribers receive matching events only.
+- Maintains global and per-session recent buffers.
+
+Current/external design:
+
+- Adds `ContextVar` fallback via `current_emitter_session_id`.
+- Explicit kwarg takes precedence.
+- Uses subscriber records to avoid double delivery.
+- Single recent events buffer.
+
+Recommendation: **Use the current/external ContextVar-capable design**, but add tests for source behavior:
+
+- Explicit session id routing.
+- ContextVar fallback routing.
+- Explicit `session_id=None` opt-out if supported.
+- Wildcard subscriber receives all events exactly once.
+- Session subscriber does not receive unrelated/no-session events.
+- No cross-session leakage with concurrent sessions.
+
+Open question: Do we need per-session recent buffers for GUGI replay, or is one global recent buffer acceptable?
+
+### 3.3 `config.py` and config schema
+
+Observed current-vs-source differences include:
+
+- Source appears to include a large `CONFIG_SCHEMA` used by `/api/config/schema`.
+- Current `api/routers/config.py` has a fallback if `CONFIG_SCHEMA` is unavailable.
+- Source has `frontend_emitter_queue_size` default around `10000` to support large responses.
+- Current returns `100` if unset.
+
+Recommendation: **Do not silently lose `CONFIG_SCHEMA` or queue capacity**. Either:
+
+1. Restore/export full schema in modular form, or
+2. Provide a new schema provider module consumed by router and GUI.
+
+Queue size should be intentionally chosen; for streaming-heavy GUI, `100` may be too small and risks event drops.
+
+### 3.4 `api/session_context.py`
+
+Current migration branch adds helper functions:
+
+- `_apply_session_model(agent, model_name)`
+- `_reload_agent_if_supported(agent)`
+
+These make model switching robust when legacy/migrated agents lack `set_session_model` or reload hooks.
+
+Recommendation: **Keep compatibility helpers** unless deeper inspection proves all agents implement required methods.
+
+Required tests:
+
+- Switch model for code-puppy agent.
+- Switch model for any older/simple agent lacking the method.
+- Switch agent then model and verify session-local model is preserved.
+
+### 3.5 `tools/command_runner.py`
+
+Current migration branch adds fallback for missing `on_run_shell_command_output` callback import.
+
+Recommendation: Keep if current branch compatibility requires it, but test terminal command streaming and callback registration.
+
+---
+
+## 4. Proposed Modular Architecture
+
+The current `api/ws/chat_handler.py` is too large and risky to maintain. Refactor only **after parity tests protect behavior**.
+
+Proposed module split:
+
+```text
+code_puppy/api/ws/
+ chat_handler.py # thin route orchestration only
+ chat/
+ __init__.py
+ context.py # per-connection/session state object
+ lifecycle.py # connect/init/restore/cleanup
+ dispatch.py # client message dispatch table
+ session_ops.py # switch/create/restore session
+ agent_ops.py # switch agent/model
+ cwd_ops.py # set working directory + persistence
+ config_ops.py # get/set config WS handlers
+ permission_ops.py # permission response/cancel plumbing
+ streaming.py # run agent and coordinate stream lifecycle
+ drain.py # frontend emitter draining/event batching
+ tool_lifecycle.py # tool ids, args deltas, tool results/groups
+ persistence.py # save turn/session/tool/message data
+ errors.py # typed error helpers
+```
+
+DB query split after tests:
+
+```text
+code_puppy/api/db/
+ connection.py
+ sessions.py
+ messages.py
+ tool_calls.py
+ compaction.py
+ queries.py # compatibility re-export layer temporarily
+```
+
+Config split option:
+
+```text
+code_puppy/config/
+ schema.py # GUI schema metadata
+ accessors.py # get/set helpers or wrappers
+```
+
+Migration rule: keep old import paths via compatibility wrappers until GUI and tests are stable.
+
+---
+
+## 5. Execution Options
+
+### Option A — Big-bang copy, then fix
+
+Copy source branch API/WS files wholesale over current repo, then resolve breakage.
+
+Pros:
+
+- Fastest initial copy.
+- Lowest chance of missing a source file.
+
+Cons:
+
+- High regression risk.
+- May overwrite already-needed frontend emitter fixes in this repo.
+- Hard to isolate bugs.
+- Does not respect modularity goal until later.
+
+Recommendation: **Do not choose** except as emergency baseline branch.
+
+### Option B — Current migration branch as base + targeted delta reconciliation
+
+Treat `feature/puppy-desk-migration` as existing partial migration. Reconcile only source-vs-current gaps, preserve current repo emitter/frontend fixes, then test.
+
+Pros:
+
+- Most efficient because API stack already exists in current branch.
+- Preserves already-made frontend emitter changes.
+- Smaller deltas to review.
+- Safer than big-bang.
+
+Cons:
+
+- Requires careful audit that current branch did not accidentally drop source behavior.
+- Existing uncommitted app-code changes need review/ownership.
+- Long `chat_handler.py` remains until post-parity refactor.
+
+Recommendation: **Best default path**.
+
+### Option C — Clean-room modular port from source into new branch off `main`
+
+Start fresh from `main`, duplicate legacy into `legacy/`, port features module-by-module into modular architecture from the beginning.
+
+Pros:
+
+- Cleanest long-term architecture.
+- Avoids carrying accidental partial migration decisions.
+- Most aligned with modularity goal.
+
+Cons:
+
+- Slowest path.
+- Higher risk of feature omissions during rewrite.
+- GUGI validation delayed.
+
+Recommendation: Use only if current migration branch is considered untrustworthy.
+
+### Option D — Compatibility route side-by-side, then same-route swap
+
+Add migrated API under a temporary side route/namespace, keep legacy route active, run parity tests against both, then switch same route after approval.
+
+Pros:
+
+- Safest runtime rollout.
+- Easy rollback.
+- Lets GUGI compare old/new behavior before final route swap.
+
+Cons:
+
+- More temporary routing/code complexity.
+- Requires duplicated route plumbing.
+
+Recommendation: Combine with **Option B** if route-level safety is important.
+
+---
+
+## 6. Recommended Path
+
+Recommended execution path: **Option B + Option D safeguards**
+
+1. Continue from `feature/puppy-desk-migration`, or create a fresh branch off `main` and replay the current migration branch cleanly.
+2. Before touching behavior, duplicate legacy/current WS route implementation into `legacy/` namespace.
+3. Preserve current frontend emitter improvements unless parity tests show behavior loss.
+4. Reconcile source-vs-current differences explicitly:
+ - Keep safer CWD history behavior.
+ - Restore or modularize `CONFIG_SCHEMA` if GUI needs it.
+ - Choose emitter recent-buffer policy.
+ - Keep session model compatibility helpers.
+ - Validate command runner callback fallback.
+5. Add parity tests before same-route swap.
+6. Run GUGI validation against temporary/new route.
+7. Only after parity + GUGI pass, swap same route with approval.
+8. Stabilize.
+9. Refactor large files into modules under test coverage.
+10. Cleanup legacy only after explicit user approval.
+
+---
+
+## 7. Approval Gates
+
+### Gate 1 — Inventory and migration matrix
+
+Status: **in progress / docs only**
+
+Deliverables:
+
+- Investigation checkpoint saved.
+- Migration matrix/options saved.
+- User confirms preferred path and goals.
+
+Approval needed before Gate 2.
+
+### Gate 2 — Branch hygiene and legacy duplication
+
+No behavior changes except copying legacy/current route code into `legacy/` namespace.
+
+Acceptance:
+
+- Working branch confirmed not `main`.
+- Legacy namespace created.
+- Existing route behavior unchanged.
+- Smoke tests pass.
+
+### Gate 3 — Parity test harness
+
+Acceptance:
+
+- Tests cover all high-risk GUI protocol features:
+ - connect/session restored
+ - user message -> assistant start/delta/end/stream_end order
+ - tool call args, result, `tool_group_id`
+ - permission request/approve/deny/timeout
+ - CWD set/unchanged/persistence
+ - switch agent/model/session
+ - config get/set/schema
+ - terminal command result/streaming if route active
+ - multiple sessions in parallel with no emitter leakage
+ - cancel during streaming
+ - switch session during streaming + background save
+- Coverage matrix includes happy path, edge/boundary, negative/error, regression.
+
+### Gate 4 — Targeted migration reconciliation
+
+Acceptance:
+
+- Source-vs-current gaps resolved with documented choices.
+- No known source feature loss.
+- Tests pass.
+- Snyk scan completed if dependency files are changed.
+
+### Gate 5 — GUGI validation on temporary/new route
+
+Acceptance:
+
+- GUGI can connect.
+- Chat streams correctly.
+- Tool panels show args/results.
+- Permission UI works.
+- Sessions list/load/switch correctly.
+- CWD/model/agent/config UI flows work.
+- Parallel sessions do not cross streams/events.
+
+### Gate 6 — Same-route swap
+
+Only after Gate 5 approval.
+
+Acceptance:
+
+- Same route uses migrated implementation.
+- Legacy still retained for rollback.
+- Full parity and GUGI smoke pass.
+
+### Gate 7 — Modular cleanup
+
+Only after migrated route is stable.
+
+Acceptance:
+
+- `chat_handler.py` split into modules.
+- `db/queries.py` split or compatibility-layered.
+- Import compatibility retained as needed.
+- Tests remain green.
+
+### Gate 8 — Legacy removal
+
+Only after explicit user approval.
+
+Acceptance:
+
+- No GUGI dependency on legacy namespace.
+- Rollback plan no longer needed or separately archived.
+- Legacy code removed and docs updated.
+
+---
+
+## 8. Test Coverage Matrix Proposal
+
+| ID | Requirement | Scenario | Risk Tag | Test Type | Status |
+|---|---|---|---|---|---|
+| T1 | WS connect/session restore | Existing session loads messages/meta | happy-path | automated WS | planned |
+| T2 | Streaming order | start -> delta(s) -> end -> stream_end | regression | automated WS | planned |
+| T3 | Delta before start robustness | handler emits synthetic start first | edge | automated unit/WS | planned |
+| T4 | Tool call args | args deltas accumulate into full args | happy-path | automated WS | planned |
+| T5 | Tool result | result emitted with `tool_group_id` | regression | automated WS | partially existing |
+| T6 | Permission approve | tool proceeds after approval | happy-path | automated WS | planned |
+| T7 | Permission deny | tool blocked and error/result surfaced safely | negative | automated WS | planned |
+| T8 | Permission timeout/no WS | fail safe | negative | unit | planned |
+| T9 | CWD set | path changes, persists, emits banner | happy-path | automated WS/DB | planned |
+| T10 | CWD unchanged | unchanged flag, no duplicate corruption | edge | automated WS | planned |
+| T11 | Switch model | session-local model changes and persists | happy-path | automated WS | planned |
+| T12 | Agent without model setter | fallback does not crash | edge | unit | planned |
+| T13 | Switch session during stream | old session saves in background | regression | automated WS | planned |
+| T14 | Parallel sessions | no emitter leakage/crossed events | regression | automated concurrent WS | planned |
+| T15 | Cancel stream | cancellation frame and cleanup | negative | automated WS | planned |
+| T16 | Config schema | schema endpoint returns GUI-usable metadata | happy-path | API | planned/current extra test |
+| T17 | Config missing schema fallback | endpoint still works | edge | API | current extra test |
+| T18 | Terminal command | command result and output callback flow | happy-path | API/WS | planned |
+| T19 | DB fresh init | empty DB creates schema v4 | happy-path | DB temp file | planned |
+| T20 | DB migration/dedup | older schema upgrades safely | regression | DB temp file | planned |
+| T21 | GUGI chat smoke | GUI sends prompt and renders stream | happy-path | manual/browser | planned |
+| T22 | GUGI tools/permissions | GUI renders tool args/results + approval | high-risk manual | manual/browser | planned |
+
+---
+
+## 9. Open Questions for User
+
+1. Should the existing `feature/puppy-desk-migration` branch be treated as the starting point, or should we create a brand-new clean branch off `main` and replay only approved changes?
+2. Is a temporary side-by-side route acceptable for validation, or do you want legacy namespace only and then same-route swap?
+3. For frontend emitter recent events, do you need per-session recent replay, or is global recent replay acceptable if session-filtered live delivery is correct?
+4. Should `/api/config/schema` expose the full rich `CONFIG_SCHEMA` for GUGI, or is inferred fallback metadata enough?
+5. What is the minimum GUGI validation flow you want before same-route swap?
+6. Are the current uncommitted application-code changes on `feature/puppy-desk-migration` yours and intended to be preserved?
+
+---
+
+## 10. Proposed Immediate Next Step After Approval
+
+If approved, proceed to **Gate 2**:
+
+- Confirm/create safe migration branch off `main`.
+- Snapshot current status.
+- Duplicate legacy/current WS route implementation into `legacy/` namespace.
+- Make no behavior switch yet.
+- Add initial parity tests around existing behavior.
+
+
+---
+
+## 11. User Decisions Recorded — 2026-06-01
+
+Confirmed by user:
+
+1. **Use existing `feature/puppy-desk-migration` as the base.**
+ - Do not restart from a fully clean branch unless a later blocker requires it.
+ - Existing migration branch decisions and compatibility fixes are considered valuable.
+
+2. **Temporary side-by-side route is acceptable.**
+ - We may expose migrated/experimental WS/API behavior behind a temporary validation route while preserving legacy/current route behavior.
+ - Same-route swap remains gated by parity + GUGI validation + explicit approval.
+
+3. **Preserve uncommitted app-code changes on `feature/puppy-desk-migration`.**
+ - Current modified files must not be overwritten casually:
+ - `code_puppy/config.py`
+ - `code_puppy/plugins/frontend_emitter/emitter.py`
+ - `code_puppy/session_storage.py`
+ - `code_puppy/tools/command_runner.py`
+ - `pyproject.toml`
+ - `uv.lock`
+ - Before Gate 2 implementation, snapshot/diff these changes and treat them as intentional unless user says otherwise.
+
+## 12. GUGI Recent-Event Replay Decision Detail
+
+Open decision: whether GUGI needs **per-session recent-event replay** or whether a **global recent-event replay buffer** is acceptable when live delivery is correctly session-filtered.
+
+### What “recent-event replay” means
+
+The frontend emitter supports live event subscription, but it can also keep a small in-memory buffer of recent events. When GUGI reconnects, refreshes, or opens a panel late, it may ask for recent events to catch up on tool calls, stream chunks, permission events, or agent invocation events that occurred just before the subscriber attached.
+
+Live routing and replay routing are separate concerns:
+
+- **Live routing**: while connected, does session A only receive session A events?
+- **Replay routing**: after reconnect/late subscribe, which older events are offered back to GUGI?
+
+A system can have correct live routing but still leak or confuse replayed events if the recent buffer is global and the replay API does not filter carefully.
+
+### Option R1 — Global recent buffer only
+
+All events are stored in one recent-events list, each event includes optional `session_id`.
+
+Expected behavior:
+
+- Wildcard callers can inspect all recent events.
+- GUGI/session-specific callers must filter by `session_id` before display.
+
+Pros:
+
+- Simpler implementation.
+- Lower memory overhead.
+- Easier backward compatibility with older `get_recent_events()` semantics.
+- Matches the newer external emitter design observed in `external-ws-replica-test`.
+
+Cons:
+
+- Higher risk of accidental cross-session replay if any consumer forgets to filter.
+- More frontend/API discipline required.
+- A busy session can evict another session’s recent events from the shared buffer.
+- Reconnecting to a quiet session after a busy parallel session may miss relevant older events.
+
+Best if:
+
+- GUGI mostly relies on DB/session state for reloads, not emitter replay.
+- Recent events are only diagnostic or best-effort.
+- Event volume is moderate.
+- All replay call sites are strongly typed and tested for session filtering.
+
+### Option R2 — Per-session recent buffers
+
+Each session has its own recent-events list; optionally a global buffer is retained for legacy/wildcard subscribers.
+
+Expected behavior:
+
+- `get_recent_events(session_id="A")` returns only session A events.
+- `get_recent_events()` returns legacy/global events or all events depending on compatibility policy.
+
+Pros:
+
+- Safer isolation by default.
+- Prevents cross-session replay leakage even if frontend forgets to filter.
+- Busy session B cannot evict session A’s recent events from A’s own buffer.
+- Better for multi-tab/multi-session GUI behavior.
+- Better fit for strict session isolation requirement.
+
+Cons:
+
+- More state to manage and clean up.
+- Need buffer cleanup when sessions are destroyed/idle.
+- Slightly more complex compatibility semantics.
+- Must decide whether no-session/global events should be visible to session subscribers.
+
+Best if:
+
+- GUGI uses recent replay to reconstruct transient UI such as active tool calls or streaming panels.
+- Multiple sessions can be active in parallel.
+- Strict no-cross-session leakage is more important than simplicity.
+- Reconnect/refresh behavior matters.
+
+### Option R3 — Hybrid: global buffer plus per-session indexed replay
+
+Maintain a global recent buffer for backward compatibility and diagnostics, but expose session-filtered replay APIs that are safe by default. Internally this can be implemented either as:
+
+- a global list filtered by `session_id`, plus tests, or
+- true per-session buffers plus a global legacy buffer.
+
+Pros:
+
+- Preserves backward compatibility.
+- Allows safe GUGI code path: always request replay by session.
+- Can start with global-filtered implementation and upgrade to true per-session if eviction becomes a problem.
+
+Cons:
+
+- More policy decisions to document.
+- If implemented only as global-filtered, shared-buffer eviction risk remains.
+
+Recommended default for migration:
+
+**R3 with true per-session replay where practical.**
+
+Reasoning:
+
+- User explicitly requires async independent session management and multiple parallel sessions.
+- GUI correctness depends on not crossing tool calls, deltas, permission prompts, or terminal output between sessions.
+- The source branch already had per-session recent buffers, which is a useful safety feature.
+- The newer current/external emitter adds ContextVar routing and double-delivery prevention, which is also valuable.
+- Best final design is to combine both:
+ - ContextVar-capable emission/routing from current/external work.
+ - Per-session replay isolation from source behavior.
+ - Legacy wildcard/global support retained for compatibility.
+
+Suggested policy:
+
+1. Live session subscribers receive only exact `session_id` matches.
+2. Wildcard subscribers receive all events exactly once.
+3. `get_recent_events(session_id="A")` returns only session A events.
+4. `get_recent_events()` retains old behavior for legacy/debug consumers.
+5. Explicit `session_id=None` events are global/broadcast for wildcard/debug only, not replayed to session-specific callers unless explicitly requested.
+6. Session recent buffers are capped and cleaned up when sessions expire.
+
+Required tests:
+
+- Two sessions emit interleaved events; each session replay returns only its own events.
+- Wildcard replay returns all expected events with no duplicates.
+- Busy session B does not evict session A’s per-session recent events.
+- ContextVar fallback attaches the correct session id.
+- Explicit kwarg overrides ContextVar.
+- Explicit `session_id=None` behavior is documented and tested.
+- Reconnect/late subscribe during tool call does not show another session’s tool event.
+
+
+---
+
+## 13. User Clarification — GUGI State Replay Source
+
+User clarified:
+
+- Recent replay/state reconstruction is handled by **React GUI state** or by the **DB**.
+- Both React GUI state and DB-backed state are already separated by `session_id`.
+
+Updated implication:
+
+- The frontend emitter recent-events buffer is **not** the primary source of truth for GUGI session reconstruction.
+- Therefore, we do **not** need to over-optimize emitter replay as the authoritative reconnect/reload mechanism.
+- However, live event routing remains critical: active stream/tool/permission events must never cross sessions.
+
+Updated recommendation:
+
+Use the current/external emitter direction as the base:
+
+1. Keep **ContextVar-capable live routing**.
+2. Keep explicit `session_id` routing and exact-match session subscribers.
+3. Keep wildcard/global subscribers for legacy/debugging.
+4. Treat emitter recent replay as **debug/best-effort compatibility**, not authoritative GUI state.
+5. Ensure any replay API that GUGI might call can filter by `session_id`, even if internally stored in one global buffer.
+6. Do not require true per-session recent buffers unless later GUGI testing shows replay eviction/leakage causes real UX issues.
+
+Revised preference:
+
+- **Global recent buffer is acceptable** if:
+ - live delivery is strictly session-filtered,
+ - replay consumers filter by `session_id`,
+ - DB/React remain the source of truth for state reconstruction,
+ - tests prove no live cross-session leakage.
+
+Still required tests:
+
+- Concurrent session live events do not cross.
+- Wildcard subscribers receive all events exactly once.
+- Session subscribers receive only matching session events.
+- ContextVar fallback attaches correct session id.
+- Explicit `session_id` overrides ContextVar.
+- Replay filtering by `session_id` works if exposed/used.
+
+Priority change:
+
+- Per-session recent buffers move from **recommended default** to **optional enhancement**.
+- Live session routing and DB/React session correctness are the hard requirements.
+
+
+---
+
+## 14. Gate 2 Implementation Shape — Proposed, Pending Explicit Approval
+
+User has approved the strategy direction, but application-code changes should still be gated explicitly.
+
+Confirmed base:
+
+- Continue on `feature/puppy-desk-migration`.
+- Preserve uncommitted app-code changes.
+- Temporary side-by-side route is acceptable.
+- Do not touch `main`.
+
+### Current route shape observed
+
+- `code_puppy/api/app.py` calls `setup_websocket(app)`.
+- `code_puppy/api/websocket.py` calls:
+ - `register_events_endpoint(app)`
+ - `register_chat_endpoint(app)`
+ - `register_terminal_endpoint(app)`
+ - `register_health_endpoint(app)`
+ - `register_sessions_endpoint(app)`
+- `code_puppy/api/ws/chat_handler.py` defines `register_chat_endpoint(app)` and registers hardcoded route:
+ - `@app.websocket("/ws/chat")`
+
+### Gate 2 goal
+
+Preserve current behavior before any migration/refactor work by duplicating the current route implementation into a legacy namespace, then prepare for side-by-side validation.
+
+### Gate 2 code-change options
+
+#### G2-A — Archive-only legacy namespace
+
+Copy the current `code_puppy/api/ws` implementation into:
+
+```text
+code_puppy/api/ws/legacy/
+```
+
+Do not register the legacy copy yet.
+
+Pros:
+
+- Lowest behavioral risk.
+- Existing `/ws/chat` route remains unchanged.
+- Gives rollback/reference copy before edits.
+
+Cons:
+
+- Not enough by itself for side-by-side live validation.
+
+#### G2-B — Parameterized route registration
+
+Change endpoint registration functions to accept an optional path, defaulting to current paths:
+
+```python
+def register_chat_endpoint(app: FastAPI, path: str = "/ws/chat") -> None:
+ @app.websocket(path)
+ async def websocket_chat(...):
+ ...
+```
+
+Then current behavior remains unchanged because default is still `/ws/chat`.
+
+Later, a temporary route can register the same or modular implementation at e.g.:
+
+```text
+/ws/chat-migration
+/ws/chat-next
+/ws/chat-v2
+```
+
+Pros:
+
+- Small, controlled change.
+- Enables side-by-side validation without copying a 230KB handler again.
+- Keeps same-route swap simple.
+
+Cons:
+
+- Touches the huge handler early.
+- Must be covered by a smoke test verifying `/ws/chat` still registers.
+
+#### G2-C — New route module wrapping copied implementation
+
+Create a separate temporary route module that copies/wraps the current chat handler under a different path.
+
+Pros:
+
+- Avoids parameterizing current handler.
+- Side-by-side route available immediately.
+
+Cons:
+
+- Duplicates very large code.
+- Increases risk of divergence and merge pain.
+
+### Recommended Gate 2 path
+
+Recommended: **G2-A first, then G2-B only when we are ready to register side-by-side.**
+
+Sequence:
+
+1. Snapshot current uncommitted changes. ✅ done in docs
+2. Copy current WS/API route implementation into `code_puppy/api/ws/legacy/` as a reference/rollback namespace.
+3. Do not register legacy yet.
+4. Add minimal smoke tests/import tests proving current `/ws/chat` registration still works.
+5. Once tests are in place, parameterize `register_chat_endpoint(..., path="/ws/chat")` and register temporary route only when needed.
+
+### Proposed temporary route name
+
+Preferred temporary route:
+
+```text
+/ws/chat-migration
+```
+
+Alternatives:
+
+```text
+/ws/chat-next
+/ws/chat-v2
+```
+
+Recommendation: `/ws/chat-migration` because it is explicit and unlikely to be confused with final versioning.
+
+### Gate 2 acceptance checks
+
+Before marking Gate 2 complete:
+
+- `git branch --show-current` is not `main`.
+- Existing uncommitted changes remain preserved.
+- Legacy namespace exists as a copy/reference.
+- `/ws/chat` behavior is unchanged.
+- No same-route swap has occurred.
+- Minimal import/route registration tests pass.
+- No dependency/security scan required unless dependency files change beyond preserved existing state; if package files are touched, Snyk check is required before testing phase.
+
+
+---
+
+## 15. Gate 2 Execution Evidence — 2026-06-01
+
+Gate 2 approved by user and executed on branch `feature/puppy-desk-migration`.
+
+Application/test changes made:
+
+- Created legacy WS snapshot namespace:
+ - `code_puppy/api/ws/legacy/`
+- Copied current WS implementation files into that namespace as a rollback/reference snapshot.
+- Replaced `legacy/__init__.py` with a safe non-registering marker module.
+- Added `code_puppy/api/ws/legacy/README.md` documenting that it is not registered and must not be cleaned up without approval.
+- Added smoke tests:
+ - `code_puppy/api/tests/test_gate2_legacy_ws_namespace.py`
+
+Behavioral guarantees for Gate 2:
+
+- Existing `/ws/chat` route remains registered from active `code_puppy.api.ws.chat_handler`.
+- No temporary route is registered yet.
+- No same-route swap occurred.
+- Legacy snapshot is importable as a namespace marker only; importing `code_puppy.api.ws.legacy` does not register routes.
+- Existing uncommitted app-code changes were preserved.
+
+Targeted test evidence:
+
+```text
+uv run pytest code_puppy/api/tests/test_gate2_legacy_ws_namespace.py -q
+2 passed in 19.57s
+```
+
+Security note:
+
+- Gate 2 did not intentionally modify dependency files.
+- `pyproject.toml` and `uv.lock` were already modified before Gate 2 and are preserved as user-approved existing changes.
+- Snyk scan should still be run before broader test/security signoff if those dependency changes remain part of the migration deliverable.
+
+
+### Gate 2 reviewer sign-off
+
+Independent code review agent result: **APPROVE**
+
+Reviewer findings summary:
+
+- No `/ws/chat` behavior switch.
+- No temporary route registered.
+- Legacy package import is marker-only/non-registering.
+- Existing uncommitted app-code changes remain preserved.
+- Smoke tests are sufficient for Gate 2 scope.
+
+Non-blocking caution recorded:
+
+- The legacy snapshot is archive/reference quality, not yet a fully executable rollback route, because copied modules still contain absolute imports to active `code_puppy.api.ws.*` modules. This is acceptable for Gate 2 archive/non-registration, but must be addressed before using `legacy` as a live rollback route.
+
diff --git a/docs/migration/puppy-desk-uncommitted-change-snapshot.md b/docs/migration/puppy-desk-uncommitted-change-snapshot.md
new file mode 100644
index 000000000..a4e325d90
--- /dev/null
+++ b/docs/migration/puppy-desk-uncommitted-change-snapshot.md
@@ -0,0 +1,1567 @@
+# Puppy Desk Migration — Uncommitted Change Snapshot
+
+Purpose: preserve and document existing uncommitted app-code changes on `feature/puppy-desk-migration` before Gate 2.
+
+Generated: 2026-06-01T15:15:33Z
+
+## Branch and status
+```text
+feature/puppy-desk-migration
+ M code_puppy/config.py
+ M code_puppy/plugins/frontend_emitter/emitter.py
+ M code_puppy/session_storage.py
+ M code_puppy/tools/command_runner.py
+ M pyproject.toml
+ M uv.lock
+?? docs/migration/
+```
+
+## Diff stat
+```text
+ code_puppy/config.py | 44 ++-
+ code_puppy/plugins/frontend_emitter/emitter.py | 13 +-
+ code_puppy/session_storage.py | 464 ++++++++++---------------
+ code_puppy/tools/command_runner.py | 372 ++++++++++++++------
+ pyproject.toml | 6 +
+ uv.lock | 220 ++++++++++++
+ 6 files changed, 715 insertions(+), 404 deletions(-)
+```
+
+## File-level notes
+
+| File | Observed status | Migration handling |
+|---|---|---|
+| `code_puppy/config.py` | Modified; includes config/schema/default differences vs source branch | Preserve and reconcile intentionally; do not overwrite wholesale |
+| `code_puppy/plugins/frontend_emitter/emitter.py` | Modified; current/external ContextVar-capable emitter direction | Preserve as base; ensure strict live session filtering |
+| `code_puppy/session_storage.py` | Modified relative to branch, but source/current comparison previously showed same as source | Preserve until inspected; likely session compatibility work |
+| `code_puppy/tools/command_runner.py` | Modified; observed callback import fallback | Preserve unless tests show callback breakage |
+| `pyproject.toml` | Modified dependency/project config | Preserve; requires Snyk scan before test phase if dependency changes remain |
+| `uv.lock` | Modified lockfile | Preserve; requires Snyk scan before test phase if dependency changes remain |
+
+## Detailed diff excerpts / full current diff for tracked modified files
+
+> This section intentionally captures the current tracked diff so it can be recovered if later work accidentally overwrites it.
+
+```diff
+diff --git a/code_puppy/config.py b/code_puppy/config.py
+index 997d7935..a2d058b8 100644
+--- a/code_puppy/config.py
++++ b/code_puppy/config.py
+@@ -48,18 +48,38 @@ EXTRA_MODELS_FILE = os.path.join(DATA_DIR, "extra_models.json")
+ AGENTS_DIR = os.path.join(DATA_DIR, "agents")
+ SKILLS_DIR = os.path.join(DATA_DIR, "skills")
+ CONTEXTS_DIR = os.path.join(DATA_DIR, "contexts")
+-
+-# OAuth plugin model files (XDG_DATA_HOME)
+-GEMINI_MODELS_FILE = os.path.join(DATA_DIR, "gemini_models.json")
+-CHATGPT_MODELS_FILE = os.path.join(DATA_DIR, "chatgpt_models.json")
+-CLAUDE_MODELS_FILE = os.path.join(DATA_DIR, "claude_models.json")
+-COPILOT_MODELS_FILE = os.path.join(DATA_DIR, "copilot_models.json")
++_DEFAULT_SQLITE_FILE = os.path.join(DATA_DIR, "dbos_store.sqlite")
+
+ # Cache files (XDG_CACHE_HOME)
+ AUTOSAVE_DIR = os.path.join(CACHE_DIR, "autosaves")
++WS_SESSION_DIR = os.path.join(CACHE_DIR, "ws_sessions")
++
++def get_ws_sessions_dir() -> pathlib.Path:
++ """Return the ws_sessions directory path and ensure it exists."""
++ p = pathlib.Path(WS_SESSION_DIR)
++ p.mkdir(parents=True, exist_ok=True, mode=0o700)
++ return p
+
+ # State files (XDG_STATE_HOME)
+ COMMAND_HISTORY_FILE = os.path.join(STATE_DIR, "command_history.txt")
++DBOS_DATABASE_URL = os.environ.get(
++ "DBOS_SYSTEM_DATABASE_URL", f"sqlite:///{_DEFAULT_SQLITE_FILE}"
++)
++# DBOS enable switch is controlled solely via puppy.cfg using key 'enable_dbos'.
++# Default: True (DBOS enabled) unless explicitly disabled.
++
++def get_use_dbos() -> bool:
++ """Return True if DBOS should be used based on 'enable_dbos' (default True)."""
++ cfg_val = get_value("enable_dbos")
++ if cfg_val is None:
++ return True
++ return str(cfg_val).strip().lower() in {"1", "true", "yes", "on"}
++
++# OAuth plugin model files (XDG_DATA_HOME)
++GEMINI_MODELS_FILE = os.path.join(DATA_DIR, "gemini_models.json")
++CHATGPT_MODELS_FILE = os.path.join(DATA_DIR, "chatgpt_models.json")
++CLAUDE_MODELS_FILE = os.path.join(DATA_DIR, "claude_models.json")
++COPILOT_MODELS_FILE = os.path.join(DATA_DIR, "copilot_models.json")
+
+
+ def get_subagent_verbose() -> bool:
+@@ -184,6 +204,18 @@ def get_enable_streaming() -> bool:
+ return str(val).lower() in ("1", "true", "yes", "on")
+
+
++def get_command_timeout_seconds() -> int:
++ """Return shell command timeout in seconds (default: 30)."""
++ val = get_value("command_timeout_seconds")
++ if val is None:
++ return 30
++ try:
++ parsed = int(str(val).strip())
++ except (ValueError, TypeError):
++ return 30
++ return max(1, parsed)
++
++
+ DEFAULT_SECTION = "puppy"
+ REQUIRED_KEYS = ["puppy_name", "owner_name"]
+
+diff --git a/code_puppy/plugins/frontend_emitter/emitter.py b/code_puppy/plugins/frontend_emitter/emitter.py
+index 55836d90..cd0ffab6 100644
+--- a/code_puppy/plugins/frontend_emitter/emitter.py
++++ b/code_puppy/plugins/frontend_emitter/emitter.py
+@@ -234,17 +234,20 @@ def unsubscribe(queue: "asyncio.Queue[Dict[str, Any]]") -> None:
+ )
+
+
+-def get_recent_events() -> List[Dict[str, Any]]:
++def get_recent_events(session_id: Optional[str] = None) -> List[Dict[str, Any]]:
+ """Get recent events for new subscribers.
+
+- Returns a copy of the most recent events (up to
+- ``frontend_emitter_max_recent_events``). Useful for letting new
+- WebSocket connections "catch up" on recent activity.
++ Args:
++ session_id: Optional session filter. If provided, only events whose
++ ``event["session_id"] == session_id`` are returned.
+
+ Returns:
+ A list of recent event dictionaries.
+ """
+- return _recent_events.copy()
++ events = _recent_events.copy()
++ if session_id is None:
++ return events
++ return [e for e in events if e.get("session_id") == session_id]
+
+
+ def get_subscriber_count() -> int:
+diff --git a/code_puppy/session_storage.py b/code_puppy/session_storage.py
+index d8b9201c..ab9a849b 100644
+--- a/code_puppy/session_storage.py
++++ b/code_puppy/session_storage.py
+@@ -1,338 +1,238 @@
+ """Shared helpers for persisting and restoring chat sessions.
+
+-This module centralises the pickle + metadata handling that used to live in
+-both the CLI command handler and the auto-save feature. Keeping it here helps
+-us avoid duplication while staying inside the Zen-of-Python sweet spot: simple
+-is better than complex, nested side effects are worse than deliberate helpers.
++MIGRATION NOTE: Pickle-based storage has been removed. All sessions are now
++persisted exclusively to SQLite via code_puppy.api.db.queries.
++This module retains only stateless helpers (title generation) and stubs for
++removed functionality to maintain import compatibility during the transition.
+ """
+
+ from __future__ import annotations
+
+ import json
+-import pickle
++import re
+ from dataclasses import dataclass
+ from pathlib import Path
+-from typing import Any, Callable, List
++from typing import Any, List
+
+-
+-def _safe_loads(data: bytes) -> Any:
+- """Deserialize pickle data."""
+- return pickle.loads(data) # noqa: S301
+-
+-
+-_LEGACY_SIGNED_HEADER = b"CPSESSION\x01"
+-_LEGACY_SIGNATURE_SIZE = (
+- 32 # legacy signature bytes, retained only for backward-compat parsing
+-)
++from pydantic import TypeAdapter
++from pydantic_ai.messages import ModelMessage
+
+ SessionHistory = List[Any]
+-TokenEstimator = Callable[[Any], int]
+-
+
+-@dataclass(slots=True)
+-class SessionPaths:
+- pickle_path: Path
+- metadata_path: Path
+
+-
+-@dataclass(slots=True)
++@dataclass
+ class SessionMetadata:
+- session_name: str
+- timestamp: str
++ """Metadata returned after saving a session."""
++
+ message_count: int
+ total_tokens: int
+- pickle_path: Path
+- metadata_path: Path
+- auto_saved: bool = False
+-
+- def as_serialisable(self) -> dict[str, Any]:
+- return {
+- "session_name": self.session_name,
+- "timestamp": self.timestamp,
+- "message_count": self.message_count,
+- "total_tokens": self.total_tokens,
+- "file_path": str(self.pickle_path),
+- "auto_saved": self.auto_saved,
+- }
+-
+-
+-def _extract_pickle_payload(raw: bytes) -> bytes:
+- """Return the pickle payload from raw session file bytes.
+-
+- New format is raw pickle bytes.
+- Legacy format was: header + 32-byte signature + pickle payload.
+- We no longer verify or generate signatures.
+- """
+- if raw.startswith(_LEGACY_SIGNED_HEADER):
+- offset = len(_LEGACY_SIGNED_HEADER) + _LEGACY_SIGNATURE_SIZE
+- return raw[offset:]
+- return raw
++ pickle_path: Path # Kept for compatibility, points to .json file
++ metadata_path: Path # Points to .json file (same as above)
+
+
+-def ensure_directory(path: Path) -> Path:
+- path.mkdir(parents=True, exist_ok=True)
+- return path
++def generate_heuristic_title(history: SessionHistory, max_length: int = 50) -> str:
++ """Generate a short title from the first user message in the history.
+
++ Extracts the first user message, takes the first ~50 chars, and converts
++ to a filename-safe format (lowercase, spaces to hyphens, remove special chars).
+
+-def build_session_paths(base_dir: Path, session_name: str) -> SessionPaths:
+- pickle_path = base_dir / f"{session_name}.pkl"
+- metadata_path = base_dir / f"{session_name}_meta.json"
+- return SessionPaths(pickle_path=pickle_path, metadata_path=metadata_path)
++ Handles multiple message formats:
++ 1. Pydantic-ai format: msg.kind == 'request' with msg.parts[].content
++ 2. Enhanced/wrapped format: {'msg': , 'agent': str, ...}
++ 3. Simple dict format: {'role': 'user', 'content': str}
++ """
++
++ def extract_user_content(msg: Any) -> str | None:
++ """Extract user message content from various message formats."""
++ # Handle wrapped/enhanced format: {'msg': , 'agent': ...}
++ if isinstance(msg, dict) and "msg" in msg:
++ msg = msg["msg"]
++
++ # Handle pydantic-ai format: msg.kind == 'request'
++ if hasattr(msg, "kind") and msg.kind == "request":
++ for part in getattr(msg, "parts", []):
++ if hasattr(part, "content") and isinstance(part.content, str):
++ content = part.content.strip()
++ if content:
++ return content
++
++ # Handle simple dict format: {'role': 'user', 'content': str}
++ if isinstance(msg, dict):
++ if msg.get("role") == "user" and isinstance(msg.get("content"), str):
++ content = msg["content"].strip()
++ if content:
++ return content
++
++ return None
++
++ def content_to_title(content: str) -> str:
++ """Convert content to a filename-safe kebab-case title."""
++ # Take first line or first max_length chars
++ first_line = content.split("\n")[0][:max_length]
++ # Convert to kebab-case filename-safe format
++ title = first_line.lower()
++ title = re.sub(r"[^a-z0-9\s-]", "", title) # Remove special chars
++ title = re.sub(r"\s+", "-", title) # Spaces to hyphens
++ title = re.sub(r"-+", "-", title) # Collapse multiple hyphens
++ title = title.strip("-")[:max_length]
++ return title
++
++ # Find first user message
++ for msg in history:
++ content = extract_user_content(msg)
++ if content:
++ title = content_to_title(content)
++ return title if title else "untitled-session"
++
++ return "untitled-session"
++
++
++# ---------------------------------------------------------------------------
++# Deprecated / Removed Pickle Functionality -> Replaced with JSON for Export
++# ---------------------------------------------------------------------------
+
+
+ def save_session(
+- *,
+ history: SessionHistory,
+ session_name: str,
+ base_dir: Path,
+- timestamp: str,
+- token_estimator: TokenEstimator,
+- auto_saved: bool = False,
++ timestamp: str | None = None,
++ token_estimator: Any | None = None,
++ **kwargs: Any,
+ ) -> SessionMetadata:
+- ensure_directory(base_dir)
+- paths = build_session_paths(base_dir, session_name)
+-
+- pickle_data = pickle.dumps(history)
+- tmp_pickle = paths.pickle_path.with_suffix(".tmp")
+- with tmp_pickle.open("wb") as pickle_file:
+- pickle_file.write(pickle_data)
+- tmp_pickle.replace(paths.pickle_path)
+-
+- total_tokens = sum(token_estimator(message) for message in history)
+- metadata = SessionMetadata(
+- session_name=session_name,
+- timestamp=timestamp,
++ """Save session history to a JSON file (replacing legacy pickle).
++
++ This is used for 'pinning' or exporting sessions via /dump_context.
++ """
++ base_dir.mkdir(parents=True, exist_ok=True)
++ file_path = base_dir / f"{session_name}.json"
++
++ # Try to serialize the history, handling mixed types:
++ # - ModelMessage objects: serialize via Pydantic
++ # - System dicts: serialize as plain dicts
++ try:
++ from pydantic_ai.messages import ModelMessagesTypeAdapter
++
++ history_data = []
++ for item in history:
++ # Unwrap from {msg: ..., agent: ..., ts: ...} wrapper if present
++ if isinstance(item, dict) and "msg" in item:
++ inner = item["msg"]
++ wrapper_meta = {k: v for k, v in item.items() if k != "msg"}
++
++ # Check if inner is a ModelMessage (has 'parts' attribute)
++ if hasattr(inner, "parts"):
++ # Serialize ModelMessage, then add wrapper metadata
++ try:
++ serialized = ModelMessagesTypeAdapter.dump_python(
++ [inner], mode="json"
++ )[0]
++ serialized["_wrapper"] = wrapper_meta
++ history_data.append(serialized)
++ except Exception:
++ # Fallback: keep as-is
++ history_data.append(item)
++ else:
++ # It's a system dict like {msg: 'system', ...} - keep as-is
++ history_data.append(item)
++ elif hasattr(item, "parts"):
++ # Direct ModelMessage without wrapper
++ try:
++ serialized = ModelMessagesTypeAdapter.dump_python(
++ [item], mode="json"
++ )[0]
++ history_data.append(serialized)
++ except Exception:
++ history_data.append({"_raw": str(item)})
++ else:
++ # Unknown type - keep as-is
++ history_data.append(item)
++
++ json_bytes = json.dumps(history_data, indent=2, default=str).encode("utf-8")
++ except Exception:
++ # Fallback: generic JSON dump
++ json_bytes = json.dumps(history, default=str, indent=2).encode("utf-8")
++
++ file_path.write_bytes(json_bytes)
++
++ # Calculate tokens if estimator provided
++ total_tokens = 0
++ if token_estimator:
++ for msg in history:
++ try:
++ # estimator might expect the original object or dict
++ total_tokens += token_estimator(msg)
++ except Exception:
++ pass
++
++ return SessionMetadata(
+ message_count=len(history),
+ total_tokens=total_tokens,
+- pickle_path=paths.pickle_path,
+- metadata_path=paths.metadata_path,
+- auto_saved=auto_saved,
++ pickle_path=file_path,
++ metadata_path=file_path,
+ )
+
+- tmp_metadata = paths.metadata_path.with_suffix(".tmp")
+- with tmp_metadata.open("w", encoding="utf-8") as metadata_file:
+- json.dump(metadata.as_serialisable(), metadata_file, indent=2)
+- tmp_metadata.replace(paths.metadata_path)
+-
+- return metadata
+-
+
+ def load_session(
+ session_name: str, base_dir: Path, *, allow_legacy: bool = False
+ ) -> SessionHistory:
+- # Kept for API compatibility; legacy loading is always supported now.
+- _ = allow_legacy
++ """Load session history from a JSON file."""
++ file_path = base_dir / f"{session_name}.json"
++ if not file_path.exists():
++ # Fallback check for legacy .pkl if explicitly requested?
++ legacy_path = base_dir / f"{session_name}.pkl"
++ if allow_legacy and legacy_path.exists():
++ raise NotImplementedError(
++ f"Found legacy pickle at {legacy_path} but pickle loading is removed."
++ )
++ raise FileNotFoundError(f"Session file not found: {file_path}")
+
+- paths = build_session_paths(base_dir, session_name)
+- if not paths.pickle_path.exists():
+- raise FileNotFoundError(paths.pickle_path)
++ json_data = file_path.read_bytes()
+
+- raw = paths.pickle_path.read_bytes()
+- pickle_data = _extract_pickle_payload(raw)
+- return _safe_loads(pickle_data)
++ # Try to load as ModelMessage objects
++ try:
++ adapter = TypeAdapter(List[ModelMessage])
++ return adapter.validate_json(json_data)
++ except Exception:
++ # Fallback: return as list of dicts
++ return json.loads(json_data)
+
+
+ def list_sessions(base_dir: Path) -> List[str]:
++ """List available JSON sessions."""
+ if not base_dir.exists():
+ return []
+- return sorted(path.stem for path in base_dir.glob("*.pkl"))
++ return sorted([p.stem for p in base_dir.glob("*.json")])
+
+
+ def cleanup_sessions(base_dir: Path, max_sessions: int) -> List[str]:
+- if max_sessions <= 0:
+- return []
++ """Cleanup old sessions if count exceeds max_sessions.
+
+- if not base_dir.exists():
+- return []
+-
+- candidate_paths = list(base_dir.glob("*.pkl"))
+- if len(candidate_paths) <= max_sessions:
+- return []
+-
+- sorted_candidates = sorted(
+- ((path.stat().st_mtime, path) for path in candidate_paths),
+- key=lambda item: item[0],
+- )
+-
+- stale_entries = sorted_candidates[:-max_sessions]
+- removed_sessions: List[str] = []
+- for _, pickle_path in stale_entries:
+- metadata_path = base_dir / f"{pickle_path.stem}_meta.json"
+- try:
+- pickle_path.unlink(missing_ok=True)
+- metadata_path.unlink(missing_ok=True)
+- removed_sessions.append(pickle_path.stem)
+- except OSError:
+- continue
+-
+- return removed_sessions
+-
+-
+-async def restore_autosave_interactively(base_dir: Path) -> None:
+- """Prompt the user to load an autosave session from base_dir, if any exist.
+-
+- This helper is deliberately placed in session_storage to keep autosave
+- restoration close to the persistence layer. It uses the same public APIs
+- (list_sessions, load_session) and mirrors the interactive behaviours from
+- the command handler.
++ Returns list of removed session names.
+ """
+- sessions = list_sessions(base_dir)
+- if not sessions:
+- return
+-
+- # Import locally to avoid pulling the messaging layer into storage modules
+- from datetime import datetime
++ if not base_dir.exists() or max_sessions <= 0:
++ return []
+
+- from prompt_toolkit.formatted_text import FormattedText
++ sessions = sorted(base_dir.glob("*.json"), key=lambda p: p.stat().st_mtime)
+
+- from code_puppy.agents.agent_manager import get_current_agent
+- from code_puppy.command_line.prompt_toolkit_completion import (
+- get_input_with_combined_completion,
+- )
+- from code_puppy.messaging import emit_success, emit_system_message, emit_warning
+-
+- entries = []
+- for name in sessions:
+- meta_path = base_dir / f"{name}_meta.json"
+- try:
+- with meta_path.open("r", encoding="utf-8") as meta_file:
+- data = json.load(meta_file)
+- timestamp = data.get("timestamp")
+- message_count = data.get("message_count")
+- except Exception:
+- timestamp = None
+- message_count = None
+- entries.append((name, timestamp, message_count))
+-
+- def sort_key(entry):
+- _, timestamp, _ = entry
+- if timestamp:
++ removed = []
++ if len(sessions) > max_sessions:
++ to_remove = sessions[: len(sessions) - max_sessions]
++ for p in to_remove:
+ try:
+- return datetime.fromisoformat(timestamp)
+- except ValueError:
+- return datetime.min
+- return datetime.min
+-
+- entries.sort(key=sort_key, reverse=True)
+-
+- PAGE_SIZE = 5
+- total = len(entries)
+- page = 0
+-
+- def render_page() -> None:
+- start = page * PAGE_SIZE
+- end = min(start + PAGE_SIZE, total)
+- page_entries = entries[start:end]
+- emit_system_message("Autosave Sessions Available:")
+- for idx, (name, timestamp, message_count) in enumerate(page_entries, start=1):
+- timestamp_display = timestamp or "unknown time"
+- message_display = (
+- f"{message_count} messages"
+- if message_count is not None
+- else "unknown size"
+- )
+- emit_system_message(
+- f" [{idx}] {name} ({message_display}, saved at {timestamp_display})"
+- )
+- # If there are more pages, offer next-page; show 'Return to first page' on last page
+- if total > PAGE_SIZE:
+- page_count = (total + PAGE_SIZE - 1) // PAGE_SIZE
+- is_last_page = (page + 1) >= page_count
+- remaining = total - (page * PAGE_SIZE + len(page_entries))
+- summary = (
+- f" and {remaining} more" if (remaining > 0 and not is_last_page) else ""
+- )
+- label = "Return to first page" if is_last_page else f"Next page{summary}"
+- emit_system_message(f" [6] {label}")
+- emit_system_message(" [Enter] Skip loading autosave")
+-
+- chosen_name: str | None = None
+-
+- while True:
+- render_page()
+- try:
+- selection = await get_input_with_combined_completion(
+- FormattedText(
+- [
+- (
+- "class:prompt",
+- "Pick 1-5 to load, 6 for next, or name/Enter: ",
+- )
+- ]
+- )
+- )
+- except (KeyboardInterrupt, EOFError):
+- emit_warning("Autosave selection cancelled")
+- return
+-
+- selection = (selection or "").strip()
+- if not selection:
+- return
+-
+- # Numeric choice: 1-5 select within current page; 6 advances page
+- if selection.isdigit():
+- num = int(selection)
+- if num == 6 and total > PAGE_SIZE:
+- page = (page + 1) % ((total + PAGE_SIZE - 1) // PAGE_SIZE)
+- # loop and re-render next page
+- continue
+- if 1 <= num <= 5:
+- start = page * PAGE_SIZE
+- idx = start + (num - 1)
+- if 0 <= idx < total:
+- chosen_name = entries[idx][0]
+- break
+- else:
+- emit_warning("Invalid selection for this page")
+- continue
+- emit_warning("Invalid selection; choose 1-5 or 6 for next")
+- continue
+-
+- # Allow direct typing by exact session name
+- for name, _ts, _mc in entries:
+- if name == selection:
+- chosen_name = name
+- break
+- if chosen_name:
+- break
+- emit_warning("No autosave loaded (invalid selection)")
+- # keep looping and allow another try
+-
+- if not chosen_name:
+- return
++ p.unlink()
++ removed.append(p.stem)
++ except Exception:
++ pass
+
+- try:
+- history = load_session(chosen_name, base_dir)
+- except FileNotFoundError:
+- emit_warning(f"Autosave '{chosen_name}' could not be found")
+- return
+- except Exception as exc:
+- emit_warning(f"Failed to load autosave '{chosen_name}': {exc}")
+- return
+-
+- agent = get_current_agent()
+- agent.set_message_history(history)
+-
+- # Set current autosave session id so subsequent autosaves overwrite this session
+- try:
+- from code_puppy.config import set_current_autosave_from_session_name
++ return removed
+
+- set_current_autosave_from_session_name(chosen_name)
+- except Exception:
+- pass
+-
+- total_tokens = sum(agent.estimate_tokens_for_message(msg) for msg in history)
+
+- session_path = base_dir / f"{chosen_name}.pkl"
+- emit_success(
+- f"✅ Autosave loaded: {len(history)} messages ({total_tokens} tokens)\n"
+- f"📁 From: {session_path}"
+- )
++async def restore_autosave_interactively(base_dir: Path) -> None:
++ """Deprecated: No-op."""
++ pass
+
+- # Display recent message history for context
+- try:
+- from code_puppy.command_line.autosave_menu import display_resumed_history
+
+- display_resumed_history(history)
+- except Exception:
+- pass # Don't fail if display doesn't work in non-TTY environment
++def build_session_paths(base_dir: Path, session_name: str) -> Any:
++ """Deprecated: Returns None or raises."""
++ raise NotImplementedError("Session paths are no longer used.")
+diff --git a/code_puppy/tools/command_runner.py b/code_puppy/tools/command_runner.py
+index af67632c..ff03cd23 100644
+--- a/code_puppy/tools/command_runner.py
++++ b/code_puppy/tools/command_runner.py
+@@ -1,4 +1,5 @@
+ import asyncio
++import contextvars
+ import ctypes
+ import os
+ import select
+@@ -11,6 +12,7 @@ import time
+ import traceback
+ from concurrent.futures import ThreadPoolExecutor
+ from contextlib import contextmanager
++from contextvars import ContextVar
+ from functools import partial
+ from typing import Callable, List, Literal, Optional, Set
+
+@@ -18,6 +20,12 @@ from pydantic import BaseModel
+ from pydantic_ai import RunContext
+ from rich.text import Text
+
++try:
++ from code_puppy.callbacks import on_run_shell_command_output
++except ImportError:
++ async def on_run_shell_command_output(*args, **kwargs):
++ return []
++from code_puppy.config import get_command_timeout_seconds
+ from code_puppy.messaging import ( # Structured messaging types
+ AgentReasoningMessage,
+ ShellOutputMessage,
+@@ -99,17 +107,104 @@ else:
+
+ _AWAITING_USER_INPUT = threading.Event()
+
+-# NOTE: The previous module-level ``_CONFIRMATION_LOCK`` was removed --
+-# queueing of parallel approval prompts now lives inside
+-# ``get_user_approval_async`` itself, so every caller (shell commands,
+-# destructive-command guard, force-push guard, ...) benefits without
+-# bolting on their own lock.
++_CONFIRMATION_LOCK = threading.Lock()
+
+ # Track running shell processes so we can kill them on Ctrl-C from the UI
+ _RUNNING_PROCESSES: Set[subprocess.Popen] = set()
+ _RUNNING_PROCESSES_LOCK = threading.Lock()
+ _USER_KILLED_PROCESSES = set()
+
++# Per-session process tracking via ContextVar
++_session_running_processes: ContextVar[Optional[Set[subprocess.Popen]]] = ContextVar(
++ "session_running_processes", default=None
++)
++_session_killed_processes: ContextVar[Optional[Set[int]]] = ContextVar(
++ "session_killed_processes", default=None
++)
++_session_awaiting_input: ContextVar[Optional[threading.Event]] = ContextVar(
++ "session_awaiting_input", default=None
++)
++
++
++def _get_running_processes() -> Set[subprocess.Popen]:
++ """Get the running processes set for the current session context."""
++ session_set = _session_running_processes.get(None)
++ if session_set is not None:
++ return session_set
++ return _RUNNING_PROCESSES
++
++
++def _get_killed_processes() -> Set[int]:
++ """Get the killed processes set for the current session context."""
++ session_set = _session_killed_processes.get(None)
++ if session_set is not None:
++ return session_set
++ return _USER_KILLED_PROCESSES
++
++
++def _get_awaiting_input_event() -> threading.Event:
++ """Get the awaiting-input event for the current session context."""
++ session_evt = _session_awaiting_input.get(None)
++ if session_evt is not None:
++ return session_evt
++ return _AWAITING_USER_INPUT
++
++
++def init_session_process_tracking() -> None:
++ """Initialize per-session process tracking. Call at WS session start."""
++ _session_running_processes.set(set())
++ _session_killed_processes.set(set())
++ _session_awaiting_input.set(threading.Event())
++ _session_active_stop_events.set(set())
++ _session_keyboard_refcount.set(0)
++ _session_ctrl_x_stop_event.set(None)
++ _session_ctrl_x_thread.set(None)
++
++
++def cleanup_session_process_tracking() -> None:
++ """Tear down per-session process tracking. Call at WS session end.
++
++ Kills any still-running session processes, then clears the per-session
++ process and killed-process sets to release any held Popen references.
++ """
++ # Kill any still-running session processes first
++ session_procs = _session_running_processes.get(None)
++ if session_procs is not None:
++ for p in list(session_procs):
++ try:
++ if p.poll() is None:
++ _kill_process_group(p)
++ except Exception:
++ pass
++ session_procs.clear()
++ session_killed = _session_killed_processes.get(None)
++ if session_killed is not None:
++ session_killed.clear()
++ session_stop = _session_active_stop_events.get(None)
++ if session_stop is not None:
++ for evt in session_stop:
++ evt.set() # Signal before discarding!
++ session_stop.clear()
++ # Clean up keyboard context
++ session_ctrl_x = _session_ctrl_x_stop_event.get(None)
++ if session_ctrl_x is not None:
++ session_ctrl_x.set() # Signal stop
++ session_thread = _session_ctrl_x_thread.get(None)
++ if session_thread is not None and session_thread.is_alive():
++ try:
++ session_thread.join(timeout=0.2)
++ except Exception:
++ pass
++ _session_keyboard_refcount.set(None)
++ _session_ctrl_x_stop_event.set(None)
++ _session_ctrl_x_thread.set(None)
++ # Reset to None so subsequent calls fall back to global
++ _session_running_processes.set(None)
++ _session_killed_processes.set(None)
++ _session_awaiting_input.set(None)
++ _session_active_stop_events.set(None)
++
++
+ # Global state for shell command keyboard handling
+ _SHELL_CTRL_X_STOP_EVENT: Optional[threading.Event] = None
+ _SHELL_CTRL_X_THREAD: Optional[threading.Thread] = None
+@@ -119,23 +214,97 @@ _ORIGINAL_SIGINT_HANDLER = None
+ _KEYBOARD_CONTEXT_REFCOUNT = 0
+ _KEYBOARD_CONTEXT_LOCK = threading.Lock()
+
++# Per-session keyboard context refcount (ContextVar for WS session isolation)
++_session_keyboard_refcount: ContextVar[Optional[int]] = ContextVar(
++ "session_keyboard_refcount", default=None
++)
++_session_ctrl_x_stop_event: ContextVar[Optional[threading.Event]] = ContextVar(
++ "session_ctrl_x_stop_event", default=None
++)
++_session_ctrl_x_thread: ContextVar[Optional[threading.Thread]] = ContextVar(
++ "session_ctrl_x_thread", default=None
++)
++
+ # Thread-safe registry of active stop events for concurrent shell commands
+ _ACTIVE_STOP_EVENTS: Set[threading.Event] = set()
+ _ACTIVE_STOP_EVENTS_LOCK = threading.Lock()
+
++# Per-session stop events (ContextVar for session isolation)
++_session_active_stop_events: ContextVar[Optional[Set[threading.Event]]] = ContextVar(
++ "session_active_stop_events", default=None
++)
++
++
++def _get_keyboard_refcount() -> int:
++ """Get keyboard context refcount for the current session."""
++ session_val = _session_keyboard_refcount.get(None)
++ if session_val is not None:
++ return session_val
++ return _KEYBOARD_CONTEXT_REFCOUNT
++
++
++def _set_keyboard_refcount(value: int) -> None:
++ """Set keyboard context refcount for the current session."""
++ if _session_keyboard_refcount.get(None) is not None:
++ _session_keyboard_refcount.set(value)
++ else:
++ global _KEYBOARD_CONTEXT_REFCOUNT
++ _KEYBOARD_CONTEXT_REFCOUNT = value
++
++
++def _get_ctrl_x_stop_event() -> Optional[threading.Event]:
++ """Get Ctrl+X stop event for the current session."""
++ session_evt = _session_ctrl_x_stop_event.get(None)
++ if session_evt is not None:
++ return session_evt
++ return _SHELL_CTRL_X_STOP_EVENT
++
++
++def _get_ctrl_x_thread() -> Optional[threading.Thread]:
++ """Get Ctrl+X thread for the current session."""
++ session_thread = _session_ctrl_x_thread.get(None)
++ if session_thread is not None:
++ return session_thread
++ return _SHELL_CTRL_X_THREAD
++
++
++def _get_active_stop_events() -> Set[threading.Event]:
++ """Get the active stop events set for the current session context."""
++ session_set = _session_active_stop_events.get(None)
++ if session_set is not None:
++ return session_set
++ return _ACTIVE_STOP_EVENTS
++
++
++@contextmanager
++def _guarded_set(collection, global_ref, lock):
++ """Acquire *lock* only when *collection* is the shared global *global_ref*.
++
++ Per-session sets (ContextVar-backed) are single-writer within their
++ asyncio task context and don't need locking.
++ """
++ if collection is global_ref:
++ with lock:
++ yield collection
++ else:
++ yield collection
++
++
+ # Thread pool for running blocking shell commands without blocking the event loop
+ # This allows multiple sub-agents to run shell commands in parallel
+ _SHELL_EXECUTOR = ThreadPoolExecutor(max_workers=16, thread_name_prefix="shell_cmd_")
+
+
+ def _register_process(proc: subprocess.Popen) -> None:
+- with _RUNNING_PROCESSES_LOCK:
+- _RUNNING_PROCESSES.add(proc)
++ procs = _get_running_processes()
++ with _guarded_set(procs, _RUNNING_PROCESSES, _RUNNING_PROCESSES_LOCK):
++ procs.add(proc)
+
+
+ def _unregister_process(proc: subprocess.Popen) -> None:
+- with _RUNNING_PROCESSES_LOCK:
+- _RUNNING_PROCESSES.discard(proc)
++ procs = _get_running_processes()
++ with _guarded_set(procs, _RUNNING_PROCESSES, _RUNNING_PROCESSES_LOCK):
++ procs.discard(proc)
+
+
+ def _kill_process_group(proc: subprocess.Popen) -> None:
+@@ -211,13 +380,16 @@ def kill_all_running_shell_processes() -> int:
+ Returns the number of processes signaled.
+ """
+ # Signal all active reader threads to stop
+- with _ACTIVE_STOP_EVENTS_LOCK:
+- for evt in _ACTIVE_STOP_EVENTS:
++ active_stop = _get_active_stop_events()
++ with _guarded_set(active_stop, _ACTIVE_STOP_EVENTS, _ACTIVE_STOP_EVENTS_LOCK):
++ for evt in active_stop:
+ evt.set()
+
+ procs: list[subprocess.Popen]
+- with _RUNNING_PROCESSES_LOCK:
+- procs = list(_RUNNING_PROCESSES)
++ running = _get_running_processes()
++ killed = _get_killed_processes()
++ with _guarded_set(running, _RUNNING_PROCESSES, _RUNNING_PROCESSES_LOCK):
++ procs = list(running)
+ count = 0
+ for p in procs:
+ try:
+@@ -235,7 +407,7 @@ def kill_all_running_shell_processes() -> int:
+ if p.poll() is None:
+ _kill_process_group(p)
+ count += 1
+- _USER_KILLED_PROCESSES.add(p.pid)
++ killed.add(p.pid)
+ finally:
+ _unregister_process(p)
+ return count
+@@ -243,32 +415,33 @@ def kill_all_running_shell_processes() -> int:
+
+ def get_running_shell_process_count() -> int:
+ """Return the number of currently-active shell processes being tracked."""
+- with _RUNNING_PROCESSES_LOCK:
++ running = _get_running_processes()
++ with _guarded_set(running, _RUNNING_PROCESSES, _RUNNING_PROCESSES_LOCK):
+ alive = 0
+ stale: Set[subprocess.Popen] = set()
+- for proc in _RUNNING_PROCESSES:
++ for proc in running:
+ if proc.poll() is None:
+ alive += 1
+ else:
+ stale.add(proc)
+ for proc in stale:
+- _RUNNING_PROCESSES.discard(proc)
++ running.discard(proc)
+ return alive
+
+
+ # Function to check if user input is awaited
+ def is_awaiting_user_input():
+ """Check if command_runner is waiting for user input."""
+- return _AWAITING_USER_INPUT.is_set()
++ return _get_awaiting_input_event().is_set()
+
+
+ # Function to set user input flag
+ def set_awaiting_user_input(awaiting=True):
+ """Set the flag indicating if user input is awaited."""
+ if awaiting:
+- _AWAITING_USER_INPUT.set()
++ _get_awaiting_input_event().set()
+ else:
+- _AWAITING_USER_INPUT.clear()
++ _get_awaiting_input_event().clear()
+
+ # When we're setting this flag, also pause/resume all active spinners
+ if awaiting:
+@@ -330,27 +503,11 @@ def _listen_for_ctrl_x_windows(
+ stop_event: threading.Event,
+ on_escape: Callable[[], None],
+ ) -> None:
+- """Windows-specific Ctrl-X listener.
+-
+- Pause-aware: while the agent is paused (Ctrl+T steering), we stop
+- draining ``msvcrt.kbhit()`` so the steering editor can read input
+- cleanly. See the POSIX sibling for the gory details.
+- """
++ """Windows-specific Ctrl-X listener."""
+ import msvcrt
+ import time
+
+- def _is_agent_paused() -> bool:
+- try:
+- from code_puppy.messaging.pause_controller import get_pause_controller
+-
+- return get_pause_controller().is_paused()
+- except Exception:
+- return False
+-
+ while not stop_event.is_set():
+- if _is_agent_paused():
+- time.sleep(0.05)
+- continue
+ try:
+ if msvcrt.kbhit():
+ try:
+@@ -384,19 +541,10 @@ def _listen_for_ctrl_x_posix(
+ stop_event: threading.Event,
+ on_escape: Callable[[], None],
+ ) -> None:
+- """POSIX-specific Ctrl-X listener.
+-
+- Pause-aware: while the ``PauseController`` is paused (Ctrl+T steering),
+- we drop cbreak mode and stop reading stdin so the steering prompt's
+- ``prompt_toolkit.Application`` can own the terminal cleanly. Without
+- this, every other keystroke typed into the steer editor gets eaten by
+- *this* listener's ``stdin.read(1)`` — the user sees half their input.
+- On resume we re-acquire cbreak and continue.
+- """
++ """POSIX-specific Ctrl-X listener."""
+ import select
+ import sys
+ import termios
+- import time
+ import tty
+
+ stdin = sys.stdin
+@@ -409,50 +557,9 @@ def _listen_for_ctrl_x_posix(
+ except Exception:
+ return
+
+- cbreak_active = False
+-
+- def _enter_cbreak() -> None:
+- nonlocal cbreak_active
+- if not cbreak_active:
+- tty.setcbreak(fd)
+- cbreak_active = True
+-
+- def _exit_cbreak() -> None:
+- nonlocal cbreak_active
+- if cbreak_active:
+- try:
+- termios.tcsetattr(fd, termios.TCSADRAIN, original_attrs)
+- except Exception:
+- pass
+- cbreak_active = False
+-
+- def _is_agent_paused() -> bool:
+- """Lazy + exception-safe pause check — never crash the listener."""
+- try:
+- from code_puppy.messaging.pause_controller import get_pause_controller
+-
+- return get_pause_controller().is_paused()
+- except Exception:
+- return False
+-
+ try:
+- _enter_cbreak()
++ tty.setcbreak(fd)
+ while not stop_event.is_set():
+- # Pause hand-off: release stdin and park until the steer
+- # prompt finishes. Polling every 50ms keeps stop responsive.
+- if _is_agent_paused():
+- _exit_cbreak()
+- while _is_agent_paused() and not stop_event.is_set():
+- time.sleep(0.05)
+- if stop_event.is_set():
+- return
+- try:
+- _enter_cbreak()
+- except Exception:
+- # Couldn't re-acquire raw mode — bail rather than spin.
+- return
+- continue
+-
+ try:
+ read_ready, _, _ = select.select([stdin], [], [], 0.05)
+ except Exception:
+@@ -470,11 +577,7 @@ def _listen_for_ctrl_x_posix(
+ "Ctrl+X handler raised unexpectedly; Ctrl+C still works."
+ )
+ finally:
+- _exit_cbreak()
+- try:
+- termios.tcsetattr(fd, termios.TCSADRAIN, original_attrs)
+- except Exception:
+- pass
++ termios.tcsetattr(fd, termios.TCSADRAIN, original_attrs)
+
+
+ def _spawn_ctrl_x_key_listener(
+@@ -687,13 +790,15 @@ def run_shell_command_streaming(
+ silent: bool = False,
+ ):
+ stop_event = threading.Event()
+- with _ACTIVE_STOP_EVENTS_LOCK:
+- _ACTIVE_STOP_EVENTS.add(stop_event)
++ active_stop = _get_active_stop_events()
++ with _guarded_set(active_stop, _ACTIVE_STOP_EVENTS, _ACTIVE_STOP_EVENTS_LOCK):
++ active_stop.add(stop_event)
+
+ start_time = time.time()
+ last_output_time = [start_time]
+
+- ABSOLUTE_TIMEOUT_SECONDS = 270
++ # Get the user-configured absolute timeout for shell commands
++ ABSOLUTE_TIMEOUT_SECONDS = get_command_timeout_seconds()
+
+ stdout_lines = []
+ stderr_lines = []
+@@ -963,8 +1068,9 @@ def run_shell_command_streaming(
+ )
+ get_message_bus().emit(shell_output_msg)
+
+- with _ACTIVE_STOP_EVENTS_LOCK:
+- _ACTIVE_STOP_EVENTS.discard(stop_event)
++ active_stop = _get_active_stop_events()
++ with _guarded_set(active_stop, _ACTIVE_STOP_EVENTS, _ACTIVE_STOP_EVENTS_LOCK):
++ active_stop.discard(stop_event)
+
+ if exit_code != 0:
+ time.sleep(1)
+@@ -978,7 +1084,7 @@ def run_shell_command_streaming(
+ exit_code=exit_code,
+ execution_time=execution_time,
+ timeout=False,
+- user_interrupted=process.pid in _USER_KILLED_PROCESSES,
++ user_interrupted=process.pid in _get_killed_processes(),
+ )
+
+ return ShellCommandOutput(
+@@ -992,8 +1098,9 @@ def run_shell_command_streaming(
+ )
+
+ except Exception as e:
+- with _ACTIVE_STOP_EVENTS_LOCK:
+- _ACTIVE_STOP_EVENTS.discard(stop_event)
++ active_stop = _get_active_stop_events()
++ with _guarded_set(active_stop, _ACTIVE_STOP_EVENTS, _ACTIVE_STOP_EVENTS_LOCK):
++ active_stop.discard(stop_event)
+ return ShellCommandOutput(
+ success=False,
+ command=command,
+@@ -1012,6 +1119,20 @@ async def run_shell_command(
+ timeout: int = 60,
+ background: bool = False,
+ ) -> ShellCommandOutput:
++ # Resolve CWD from the session ContextVar when not explicitly provided.
++ # This supports concurrent WebSocket sessions where each session has its own CWD
++ # without relying on the process-global os.chdir().
++ # Returns None for CLI sessions (subprocess inherits process CWD as normal).
++ if cwd is None:
++ try:
++ from code_puppy.plugins.walmart_specific.session_context import (
++ get_session_working_directory,
++ )
++
++ cwd = get_session_working_directory()
++ except ImportError:
++ pass
++
+ # Generate unique group_id for this command execution
+ group_id = generate_group_id("shell_command", command)
+
+@@ -1140,12 +1261,32 @@ async def run_shell_command(
+ # Check if we're running as a sub-agent (skip confirmation and run silently)
+ running_as_subagent = is_subagent()
+
++ confirmation_lock_acquired = False
++
++ # Check if WebSocket mode is active (permission handled via WebSocket callbacks)
++ websocket_mode_active = False
++ try:
++ from code_puppy.api.permission_plugin import get_websocket_context
++
++ websocket_mode_active = get_websocket_context() is not None
++ except (ImportError, AttributeError):
++ pass
++
+ # Only ask for confirmation if we're in an interactive TTY, not in yolo mode,
+- # and NOT running as a sub-agent (sub-agents run without user interaction)
+- if not yolo_mode and not running_as_subagent and sys.stdin.isatty():
+- # No local lock needed -- get_user_approval_async serializes
+- # parallel prompts internally so the 2nd, 3rd, 4th... destructive
+- # commands queue up cleanly instead of vanishing.
++ # NOT running as a sub-agent, and NOT in WebSocket mode (WebSocket has its own permission system)
++ if (
++ not yolo_mode
++ and not running_as_subagent
++ and not websocket_mode_active
++ and sys.stdin.isatty()
++ ):
++ confirmation_lock_acquired = _CONFIRMATION_LOCK.acquire(blocking=False)
++ if not confirmation_lock_acquired:
++ return ShellCommandOutput(
++ success=False,
++ command=command,
++ error="Another command is currently awaiting confirmation",
++ )
+
+ # Get puppy name for personalized messages
+ from code_puppy.config import get_puppy_name
+@@ -1163,8 +1304,7 @@ async def run_shell_command(
+ panel_content.append("📂 Working directory: ", style="dim")
+ panel_content.append(cwd, style="dim cyan")
+
+- # Use the common approval function (async version).
+- # Internal queueing means parallel calls wait their turn here.
++ # Use the common approval function (async version)
+ confirmed, user_feedback = await get_user_approval_async(
+ title="Shell Command",
+ content=panel_content,
+@@ -1173,6 +1313,10 @@ async def run_shell_command(
+ puppy_name=puppy_name,
+ )
+
++ # Release lock after approval
++ if confirmation_lock_acquired:
++ _CONFIRMATION_LOCK.release()
++
+ if not confirmed:
+ if user_feedback:
+ result = ShellCommandOutput(
+@@ -1313,9 +1457,13 @@ async def _run_command_inner(
+ try:
+ # Run the blocking shell command in a thread pool to avoid blocking the event loop
+ # This allows multiple sub-agents to run shell commands in parallel
++ # Copy context so ContextVar-based session tracking propagates to the worker thread
++ ctx = contextvars.copy_context()
+ return await loop.run_in_executor(
+ _SHELL_EXECUTOR,
+- partial(_run_command_sync, command, cwd, timeout, group_id, silent),
++ partial(
++ ctx.run, _run_command_sync, command, cwd, timeout, group_id, silent
++ ),
+ )
+ except Exception as e:
+ if not silent:
+@@ -1392,7 +1540,9 @@ def register_agent_run_shell_command(agent):
+
+ Supports streaming output, timeout handling, and background execution.
+ """
+- return await run_shell_command(context, command, cwd, timeout, background)
++ result = await run_shell_command(context, command, cwd, timeout, background)
++ await on_run_shell_command_output(result)
++ return result
+
+
+ def register_agent_share_your_reasoning(agent):
+diff --git a/pyproject.toml b/pyproject.toml
+index a41eef50..1c433bcc 100644
+--- a/pyproject.toml
++++ b/pyproject.toml
+@@ -9,6 +9,11 @@ description = "Code generation agent"
+ readme = "README.md"
+ requires-python = ">=3.11,<3.15"
+ dependencies = [
++ "aiosqlite>=0.22.1",
++ "websockets>=12.0",
++ "uvicorn[standard]>=0.27.0",
++ "fastapi>=0.109.0",
++ "dbos>=2.11.0",
+ "pydantic-ai-slim[openai,anthropic,mcp]==1.56.0",
+ "typer>=0.12.0",
+ "mcp>=1.9.4",
+@@ -63,6 +68,7 @@ HomePage = "https://github.com/mpfaffenberger/code_puppy"
+ [project.scripts]
+ code-puppy = "code_puppy.main:main_entry"
+ pup = "code_puppy.main:main_entry"
++code-puppy-api = "code_puppy.api.main:main"
+
+ [tool.logfire]
+ ignore_no_config = true
+diff --git a/uv.lock b/uv.lock
+index 3d7e28f8..e23624b4 100644
+--- a/uv.lock
++++ b/uv.lock
+@@ -2,6 +2,15 @@ version = 1
+ revision = 3
+ requires-python = ">=3.11, <3.15"
+
++[[package]]
++name = "aiosqlite"
++version = "0.22.1"
++source = { registry = "https://pypi.org/simple" }
++sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" }
++wheels = [
++ { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
++]
++
+ [[package]]
+ name = "annotated-doc"
+ version = "0.0.4"
+@@ -303,9 +312,12 @@ name = "code-puppy"
+ version = "0.0.527"
+ source = { editable = "." }
+ dependencies = [
++ { name = "aiosqlite" },
+ { name = "anthropic" },
+ { name = "azure-identity" },
+ { name = "boto3" },
++ { name = "dbos" },
++ { name = "fastapi" },
+ { name = "httpx", extra = ["http2"] },
+ { name = "json-repair" },
+ { name = "mcp" },
+@@ -323,6 +335,8 @@ dependencies = [
+ { name = "ripgrep" },
+ { name = "termflow-md" },
+ { name = "typer" },
++ { name = "uvicorn", extra = ["standard"] },
++ { name = "websockets" },
+ ]
+
+ [package.optional-dependencies]
+@@ -344,11 +358,14 @@ dev = [
+
+ [package.metadata]
+ requires-dist = [
++ { name = "aiosqlite", specifier = ">=0.22.1" },
+ { name = "anthropic", specifier = "==0.79.0" },
+ { name = "azure-identity", specifier = ">=1.15.0" },
+ { name = "boto3", specifier = ">=1.43.9" },
+ { name = "boto3", marker = "extra == 'bedrock'", specifier = ">=1.35.0" },
++ { name = "dbos", specifier = ">=2.11.0" },
+ { name = "dbos", marker = "extra == 'durable'", specifier = ">=2.11.0" },
++ { name = "fastapi", specifier = ">=0.109.0" },
+ { name = "httpx", extras = ["http2"], specifier = ">=0.24.1" },
+ { name = "json-repair", specifier = ">=0.46.2" },
+ { name = "mcp", specifier = ">=1.9.4" },
+@@ -366,6 +383,8 @@ requires-dist = [
+ { name = "ripgrep", specifier = "==14.1.0" },
+ { name = "termflow-md", specifier = ">=0.1.11" },
+ { name = "typer", specifier = ">=0.12.0" },
++ { name = "uvicorn", extras = ["standard"], specifier = ">=0.27.0" },
++ { name = "websockets", specifier = ">=12.0" },
+ ]
+ provides-extras = ["bedrock", "durable"]
+
+@@ -585,6 +604,22 @@ wheels = [
+ { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" },
+ ]
+
++[[package]]
++name = "fastapi"
++version = "0.136.3"
++source = { registry = "https://pypi.org/simple" }
++dependencies = [
++ { name = "annotated-doc" },
++ { name = "pydantic" },
++ { name = "starlette" },
++ { name = "typing-extensions" },
++ { name = "typing-inspection" },
++]
++sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" }
++wheels = [
++ { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" },
++]
++
+ [[package]]
+ name = "genai-prices"
+ version = "0.0.60"
+@@ -734,6 +769,49 @@ wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
+ ]
+
++[[package]]
++name = "httptools"
++version = "0.8.0"
++source = { registry = "https://pypi.org/simple" }
++sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" }
++wheels = [
++ { url = "https://files.pythonhosted.org/packages/f8/d2/c3eedaef57de65c3cc5f8dc244cf12d09c84ad258a479055aad6db23206c/httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168", size = 208428, upload-time = "2026-05-25T22:16:59.717Z" },
++ { url = "https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d", size = 113366, upload-time = "2026-05-25T22:17:00.795Z" },
++ { url = "https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376", size = 464676, upload-time = "2026-05-25T22:17:02.014Z" },
++ { url = "https://files.pythonhosted.org/packages/bf/95/4c1c26c0b985f8a3331682d802598f14e32dc41bf7509266eb2c04ad4801/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d", size = 464235, upload-time = "2026-05-25T22:17:03.109Z" },
++ { url = "https://files.pythonhosted.org/packages/a2/82/6735be2b0ca527718c431cdb8e5f70c3862c0844a687df0f572c51e11497/httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085", size = 449809, upload-time = "2026-05-25T22:17:04.443Z" },
++ { url = "https://files.pythonhosted.org/packages/b5/f9/5811c74f37a758c8a4aa3dc430375119d335947e883efc4664d8f3559a41/httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124", size = 452174, upload-time = "2026-05-25T22:17:05.476Z" },
++ { url = "https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07", size = 90991, upload-time = "2026-05-25T22:17:06.75Z" },
++ { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" },
++ { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" },
++ { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" },
++ { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" },
++ { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" },
++ { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" },
++ { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" },
++ { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" },
++ { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" },
++ { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" },
++ { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" },
++ { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" },
++ { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" },
++ { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" },
++ { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" },
++ { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" },
++ { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" },
++ { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" },
++ { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" },
++ { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" },
++ { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" },
++ { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" },
++ { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" },
++ { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" },
++ { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" },
++ { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" },
++ { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" },
++ { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" },
++]
++
+ [[package]]
+ name = "httpx"
+ version = "0.28.1"
+@@ -2367,6 +2445,148 @@ wheels = [
+ { url = "https://files.pythonhosted.org/packages/15/41/ac2dfdbc1f60c7af4f994c7a335cfa7040c01642b605d65f611cecc2a1e4/uvicorn-0.47.0-py3-none-any.whl", hash = "sha256:2c5715bc12d1892d84752049f400cd1c3cb018514967fdfeb97640443a6a9432", size = 71301, upload-time = "2026-05-14T18:16:51.762Z" },
+ ]
+
++[package.optional-dependencies]
++standard = [
++ { name = "colorama", marker = "sys_platform == 'win32'" },
++ { name = "httptools" },
++ { name = "python-dotenv" },
++ { name = "pyyaml" },
++ { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
++ { name = "watchfiles" },
++ { name = "websockets" },
++]
++
++[[package]]
++name = "uvloop"
++version = "0.22.1"
++source = { registry = "https://pypi.org/simple" }
++sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
++wheels = [
++ { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" },
++ { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" },
++ { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" },
++ { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" },
++ { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" },
++ { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" },
++ { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
++ { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
++ { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
++ { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
++ { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
++ { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
++ { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
++ { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
++ { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
++ { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
++ { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
++ { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
++ { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
++ { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
++ { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
++ { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
++ { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
++ { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
++ { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
++ { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
++ { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
++ { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
++ { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
++ { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
++]
++
++[[package]]
++name = "watchfiles"
++version = "1.2.0"
++source = { registry = "https://pypi.org/simple" }
++dependencies = [
++ { name = "anyio" },
++]
++sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" }
++wheels = [
++ { url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" },
++ { url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" },
++ { url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" },
++ { url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" },
++ { url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" },
++ { url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" },
++ { url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" },
++ { url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" },
++ { url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" },
++ { url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" },
++ { url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" },
++ { url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" },
++ { url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" },
++ { url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" },
++ { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" },
++ { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" },
++ { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" },
++ { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" },
++ { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" },
++ { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" },
++ { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" },
++ { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" },
++ { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" },
++ { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" },
++ { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" },
++ { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" },
++ { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" },
++ { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" },
++ { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" },
++ { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" },
++ { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" },
++ { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" },
++ { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" },
++ { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" },
++ { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" },
++ { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" },
++ { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" },
++ { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" },
++ { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" },
++ { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" },
++ { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" },
++ { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" },
++ { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" },
++ { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" },
++ { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" },
++ { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" },
++ { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" },
++ { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" },
++ { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" },
++ { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" },
++ { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" },
++ { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" },
++ { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" },
++ { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" },
++ { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" },
++ { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" },
++ { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" },
++ { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" },
++ { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" },
++ { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" },
++ { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" },
++ { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" },
++ { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" },
++ { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" },
++ { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" },
++ { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" },
++ { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" },
++ { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" },
++ { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" },
++ { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" },
++ { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" },
++ { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" },
++ { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" },
++ { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" },
++ { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" },
++ { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" },
++ { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" },
++ { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" },
++ { url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" },
++ { url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" },
++ { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" },
++ { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" },
++]
++
+ [[package]]
+ name = "wcwidth"
+ version = "0.7.0"
+```
From a47fe93eb39f4977491f0d3621ecb9195412dbfc Mon Sep 17 00:00:00 2001
From: shravya maddipudi
Date: Mon, 1 Jun 2026 13:56:44 -0500
Subject: [PATCH 09/29] feat(api): add puppy-desk backend runtime support
---
code_puppy/config.py | 44 +-
.../plugins/frontend_emitter/emitter.py | 13 +-
code_puppy/session_storage.py | 466 +++++++-----------
code_puppy/tools/command_runner.py | 285 +++++++++--
pyproject.toml | 6 +
5 files changed, 482 insertions(+), 332 deletions(-)
diff --git a/code_puppy/config.py b/code_puppy/config.py
index 62afbc9ea..7ddaf53a3 100644
--- a/code_puppy/config.py
+++ b/code_puppy/config.py
@@ -52,18 +52,38 @@ def _get_xdg_dir(env_var: str, fallback: str) -> str:
AGENTS_DIR = os.path.join(DATA_DIR, "agents")
SKILLS_DIR = os.path.join(DATA_DIR, "skills")
CONTEXTS_DIR = os.path.join(DATA_DIR, "contexts")
-
-# OAuth plugin model files (XDG_DATA_HOME)
-GEMINI_MODELS_FILE = os.path.join(DATA_DIR, "gemini_models.json")
-CHATGPT_MODELS_FILE = os.path.join(DATA_DIR, "chatgpt_models.json")
-CLAUDE_MODELS_FILE = os.path.join(DATA_DIR, "claude_models.json")
-COPILOT_MODELS_FILE = os.path.join(DATA_DIR, "copilot_models.json")
+_DEFAULT_SQLITE_FILE = os.path.join(DATA_DIR, "dbos_store.sqlite")
# Cache files (XDG_CACHE_HOME)
AUTOSAVE_DIR = os.path.join(CACHE_DIR, "autosaves")
+WS_SESSION_DIR = os.path.join(CACHE_DIR, "ws_sessions")
+
+def get_ws_sessions_dir() -> pathlib.Path:
+ """Return the ws_sessions directory path and ensure it exists."""
+ p = pathlib.Path(WS_SESSION_DIR)
+ p.mkdir(parents=True, exist_ok=True, mode=0o700)
+ return p
# State files (XDG_STATE_HOME)
COMMAND_HISTORY_FILE = os.path.join(STATE_DIR, "command_history.txt")
+DBOS_DATABASE_URL = os.environ.get(
+ "DBOS_SYSTEM_DATABASE_URL", f"sqlite:///{_DEFAULT_SQLITE_FILE}"
+)
+# DBOS enable switch is controlled solely via puppy.cfg using key 'enable_dbos'.
+# Default: True (DBOS enabled) unless explicitly disabled.
+
+def get_use_dbos() -> bool:
+ """Return True if DBOS should be used based on 'enable_dbos' (default True)."""
+ cfg_val = get_value("enable_dbos")
+ if cfg_val is None:
+ return True
+ return str(cfg_val).strip().lower() in {"1", "true", "yes", "on"}
+
+# OAuth plugin model files (XDG_DATA_HOME)
+GEMINI_MODELS_FILE = os.path.join(DATA_DIR, "gemini_models.json")
+CHATGPT_MODELS_FILE = os.path.join(DATA_DIR, "chatgpt_models.json")
+CLAUDE_MODELS_FILE = os.path.join(DATA_DIR, "claude_models.json")
+COPILOT_MODELS_FILE = os.path.join(DATA_DIR, "copilot_models.json")
def get_subagent_verbose() -> bool:
@@ -199,6 +219,18 @@ def get_suppress_directory_listing() -> bool:
return str(val).lower() in ("1", "true", "yes", "on")
+def get_command_timeout_seconds() -> int:
+ """Return shell command timeout in seconds (default: 30)."""
+ val = get_value("command_timeout_seconds")
+ if val is None:
+ return 30
+ try:
+ parsed = int(str(val).strip())
+ except (ValueError, TypeError):
+ return 30
+ return max(1, parsed)
+
+
DEFAULT_SECTION = "puppy"
REQUIRED_KEYS = ["puppy_name", "owner_name"]
diff --git a/code_puppy/plugins/frontend_emitter/emitter.py b/code_puppy/plugins/frontend_emitter/emitter.py
index 55836d908..cd0ffab69 100644
--- a/code_puppy/plugins/frontend_emitter/emitter.py
+++ b/code_puppy/plugins/frontend_emitter/emitter.py
@@ -234,17 +234,20 @@ def unsubscribe(queue: "asyncio.Queue[Dict[str, Any]]") -> None:
)
-def get_recent_events() -> List[Dict[str, Any]]:
+def get_recent_events(session_id: Optional[str] = None) -> List[Dict[str, Any]]:
"""Get recent events for new subscribers.
- Returns a copy of the most recent events (up to
- ``frontend_emitter_max_recent_events``). Useful for letting new
- WebSocket connections "catch up" on recent activity.
+ Args:
+ session_id: Optional session filter. If provided, only events whose
+ ``event["session_id"] == session_id`` are returned.
Returns:
A list of recent event dictionaries.
"""
- return _recent_events.copy()
+ events = _recent_events.copy()
+ if session_id is None:
+ return events
+ return [e for e in events if e.get("session_id") == session_id]
def get_subscriber_count() -> int:
diff --git a/code_puppy/session_storage.py b/code_puppy/session_storage.py
index 62c6bfad1..ab9a849bb 100644
--- a/code_puppy/session_storage.py
+++ b/code_puppy/session_storage.py
@@ -1,340 +1,238 @@
"""Shared helpers for persisting and restoring chat sessions.
-This module centralises the pickle + metadata handling that used to live in
-both the CLI command handler and the auto-save feature. Keeping it here helps
-us avoid duplication while staying inside the Zen-of-Python sweet spot: simple
-is better than complex, nested side effects are worse than deliberate helpers.
+MIGRATION NOTE: Pickle-based storage has been removed. All sessions are now
+persisted exclusively to SQLite via code_puppy.api.db.queries.
+This module retains only stateless helpers (title generation) and stubs for
+removed functionality to maintain import compatibility during the transition.
"""
from __future__ import annotations
import json
-import pickle
+import re
from dataclasses import dataclass
from pathlib import Path
-from typing import Any, Callable, List
+from typing import Any, List
-
-def _safe_loads(data: bytes) -> Any:
- """Deserialize pickle data."""
- return pickle.loads(data) # noqa: S301
-
-
-_LEGACY_SIGNED_HEADER = b"CPSESSION\x01"
-_LEGACY_SIGNATURE_SIZE = (
- 32 # legacy signature bytes, retained only for backward-compat parsing
-)
+from pydantic import TypeAdapter
+from pydantic_ai.messages import ModelMessage
SessionHistory = List[Any]
-TokenEstimator = Callable[[Any], int]
-
-@dataclass(slots=True)
-class SessionPaths:
- pickle_path: Path
- metadata_path: Path
-
-@dataclass(slots=True)
+@dataclass
class SessionMetadata:
- session_name: str
- timestamp: str
+ """Metadata returned after saving a session."""
+
message_count: int
total_tokens: int
- pickle_path: Path
- metadata_path: Path
- auto_saved: bool = False
-
- def as_serialisable(self) -> dict[str, Any]:
- return {
- "session_name": self.session_name,
- "timestamp": self.timestamp,
- "message_count": self.message_count,
- "total_tokens": self.total_tokens,
- "file_path": str(self.pickle_path),
- "auto_saved": self.auto_saved,
- }
-
-
-def _extract_pickle_payload(raw: bytes) -> bytes:
- """Return the pickle payload from raw session file bytes.
-
- New format is raw pickle bytes.
- Legacy format was: header + 32-byte signature + pickle payload.
- We no longer verify or generate signatures.
- """
- if raw.startswith(_LEGACY_SIGNED_HEADER):
- offset = len(_LEGACY_SIGNED_HEADER) + _LEGACY_SIGNATURE_SIZE
- return raw[offset:]
- return raw
+ pickle_path: Path # Kept for compatibility, points to .json file
+ metadata_path: Path # Points to .json file (same as above)
-def ensure_directory(path: Path) -> Path:
- path.mkdir(parents=True, exist_ok=True)
- return path
+def generate_heuristic_title(history: SessionHistory, max_length: int = 50) -> str:
+ """Generate a short title from the first user message in the history.
+ Extracts the first user message, takes the first ~50 chars, and converts
+ to a filename-safe format (lowercase, spaces to hyphens, remove special chars).
-def build_session_paths(base_dir: Path, session_name: str) -> SessionPaths:
- pickle_path = base_dir / f"{session_name}.pkl"
- metadata_path = base_dir / f"{session_name}_meta.json"
- return SessionPaths(pickle_path=pickle_path, metadata_path=metadata_path)
+ Handles multiple message formats:
+ 1. Pydantic-ai format: msg.kind == 'request' with msg.parts[].content
+ 2. Enhanced/wrapped format: {'msg': , 'agent': str, ...}
+ 3. Simple dict format: {'role': 'user', 'content': str}
+ """
+
+ def extract_user_content(msg: Any) -> str | None:
+ """Extract user message content from various message formats."""
+ # Handle wrapped/enhanced format: {'msg': , 'agent': ...}
+ if isinstance(msg, dict) and "msg" in msg:
+ msg = msg["msg"]
+
+ # Handle pydantic-ai format: msg.kind == 'request'
+ if hasattr(msg, "kind") and msg.kind == "request":
+ for part in getattr(msg, "parts", []):
+ if hasattr(part, "content") and isinstance(part.content, str):
+ content = part.content.strip()
+ if content:
+ return content
+
+ # Handle simple dict format: {'role': 'user', 'content': str}
+ if isinstance(msg, dict):
+ if msg.get("role") == "user" and isinstance(msg.get("content"), str):
+ content = msg["content"].strip()
+ if content:
+ return content
+
+ return None
+
+ def content_to_title(content: str) -> str:
+ """Convert content to a filename-safe kebab-case title."""
+ # Take first line or first max_length chars
+ first_line = content.split("\n")[0][:max_length]
+ # Convert to kebab-case filename-safe format
+ title = first_line.lower()
+ title = re.sub(r"[^a-z0-9\s-]", "", title) # Remove special chars
+ title = re.sub(r"\s+", "-", title) # Spaces to hyphens
+ title = re.sub(r"-+", "-", title) # Collapse multiple hyphens
+ title = title.strip("-")[:max_length]
+ return title
+
+ # Find first user message
+ for msg in history:
+ content = extract_user_content(msg)
+ if content:
+ title = content_to_title(content)
+ return title if title else "untitled-session"
+
+ return "untitled-session"
+
+
+# ---------------------------------------------------------------------------
+# Deprecated / Removed Pickle Functionality -> Replaced with JSON for Export
+# ---------------------------------------------------------------------------
def save_session(
- *,
history: SessionHistory,
session_name: str,
base_dir: Path,
- timestamp: str,
- token_estimator: TokenEstimator,
- auto_saved: bool = False,
+ timestamp: str | None = None,
+ token_estimator: Any | None = None,
+ **kwargs: Any,
) -> SessionMetadata:
- ensure_directory(base_dir)
- paths = build_session_paths(base_dir, session_name)
-
- pickle_data = pickle.dumps(history)
- tmp_pickle = paths.pickle_path.with_suffix(".tmp")
- with tmp_pickle.open("wb") as pickle_file:
- pickle_file.write(pickle_data)
- tmp_pickle.replace(paths.pickle_path)
-
- total_tokens = sum(token_estimator(message) for message in history)
- metadata = SessionMetadata(
- session_name=session_name,
- timestamp=timestamp,
+ """Save session history to a JSON file (replacing legacy pickle).
+
+ This is used for 'pinning' or exporting sessions via /dump_context.
+ """
+ base_dir.mkdir(parents=True, exist_ok=True)
+ file_path = base_dir / f"{session_name}.json"
+
+ # Try to serialize the history, handling mixed types:
+ # - ModelMessage objects: serialize via Pydantic
+ # - System dicts: serialize as plain dicts
+ try:
+ from pydantic_ai.messages import ModelMessagesTypeAdapter
+
+ history_data = []
+ for item in history:
+ # Unwrap from {msg: ..., agent: ..., ts: ...} wrapper if present
+ if isinstance(item, dict) and "msg" in item:
+ inner = item["msg"]
+ wrapper_meta = {k: v for k, v in item.items() if k != "msg"}
+
+ # Check if inner is a ModelMessage (has 'parts' attribute)
+ if hasattr(inner, "parts"):
+ # Serialize ModelMessage, then add wrapper metadata
+ try:
+ serialized = ModelMessagesTypeAdapter.dump_python(
+ [inner], mode="json"
+ )[0]
+ serialized["_wrapper"] = wrapper_meta
+ history_data.append(serialized)
+ except Exception:
+ # Fallback: keep as-is
+ history_data.append(item)
+ else:
+ # It's a system dict like {msg: 'system', ...} - keep as-is
+ history_data.append(item)
+ elif hasattr(item, "parts"):
+ # Direct ModelMessage without wrapper
+ try:
+ serialized = ModelMessagesTypeAdapter.dump_python(
+ [item], mode="json"
+ )[0]
+ history_data.append(serialized)
+ except Exception:
+ history_data.append({"_raw": str(item)})
+ else:
+ # Unknown type - keep as-is
+ history_data.append(item)
+
+ json_bytes = json.dumps(history_data, indent=2, default=str).encode("utf-8")
+ except Exception:
+ # Fallback: generic JSON dump
+ json_bytes = json.dumps(history, default=str, indent=2).encode("utf-8")
+
+ file_path.write_bytes(json_bytes)
+
+ # Calculate tokens if estimator provided
+ total_tokens = 0
+ if token_estimator:
+ for msg in history:
+ try:
+ # estimator might expect the original object or dict
+ total_tokens += token_estimator(msg)
+ except Exception:
+ pass
+
+ return SessionMetadata(
message_count=len(history),
total_tokens=total_tokens,
- pickle_path=paths.pickle_path,
- metadata_path=paths.metadata_path,
- auto_saved=auto_saved,
+ pickle_path=file_path,
+ metadata_path=file_path,
)
- tmp_metadata = paths.metadata_path.with_suffix(".tmp")
- with tmp_metadata.open("w", encoding="utf-8") as metadata_file:
- json.dump(metadata.as_serialisable(), metadata_file, indent=2)
- tmp_metadata.replace(paths.metadata_path)
-
- return metadata
-
def load_session(
session_name: str, base_dir: Path, *, allow_legacy: bool = False
) -> SessionHistory:
- # Kept for API compatibility; legacy loading is always supported now.
- _ = allow_legacy
+ """Load session history from a JSON file."""
+ file_path = base_dir / f"{session_name}.json"
+ if not file_path.exists():
+ # Fallback check for legacy .pkl if explicitly requested?
+ legacy_path = base_dir / f"{session_name}.pkl"
+ if allow_legacy and legacy_path.exists():
+ raise NotImplementedError(
+ f"Found legacy pickle at {legacy_path} but pickle loading is removed."
+ )
+ raise FileNotFoundError(f"Session file not found: {file_path}")
- paths = build_session_paths(base_dir, session_name)
- if not paths.pickle_path.exists():
- raise FileNotFoundError(paths.pickle_path)
+ json_data = file_path.read_bytes()
- raw = paths.pickle_path.read_bytes()
- pickle_data = _extract_pickle_payload(raw)
- return _safe_loads(pickle_data)
+ # Try to load as ModelMessage objects
+ try:
+ adapter = TypeAdapter(List[ModelMessage])
+ return adapter.validate_json(json_data)
+ except Exception:
+ # Fallback: return as list of dicts
+ return json.loads(json_data)
def list_sessions(base_dir: Path) -> List[str]:
+ """List available JSON sessions."""
if not base_dir.exists():
return []
- return sorted(path.stem for path in base_dir.glob("*.pkl"))
+ return sorted([p.stem for p in base_dir.glob("*.json")])
def cleanup_sessions(base_dir: Path, max_sessions: int) -> List[str]:
- if max_sessions <= 0:
- return []
-
- if not base_dir.exists():
- return []
-
- candidate_paths = list(base_dir.glob("*.pkl"))
- if len(candidate_paths) <= max_sessions:
- return []
-
- sorted_candidates = sorted(
- ((path.stat().st_mtime, path) for path in candidate_paths),
- key=lambda item: item[0],
- )
-
- stale_entries = sorted_candidates[:-max_sessions]
- removed_sessions: List[str] = []
- for _, pickle_path in stale_entries:
- metadata_path = base_dir / f"{pickle_path.stem}_meta.json"
- try:
- pickle_path.unlink(missing_ok=True)
- metadata_path.unlink(missing_ok=True)
- removed_sessions.append(pickle_path.stem)
- except OSError:
- continue
-
- return removed_sessions
+ """Cleanup old sessions if count exceeds max_sessions.
-
-async def restore_autosave_interactively(base_dir: Path) -> None:
- """Prompt the user to load an autosave session from base_dir, if any exist.
-
- This helper is deliberately placed in session_storage to keep autosave
- restoration close to the persistence layer. It uses the same public APIs
- (list_sessions, load_session) and mirrors the interactive behaviours from
- the command handler.
+ Returns list of removed session names.
"""
- sessions = list_sessions(base_dir)
- if not sessions:
- return
-
- # Import locally to avoid pulling the messaging layer into storage modules
- from datetime import datetime
+ if not base_dir.exists() or max_sessions <= 0:
+ return []
- from prompt_toolkit.formatted_text import FormattedText
+ sessions = sorted(base_dir.glob("*.json"), key=lambda p: p.stat().st_mtime)
- from code_puppy.agents.agent_manager import get_current_agent
- from code_puppy.command_line.prompt_toolkit_completion import (
- get_input_with_combined_completion,
- )
- from code_puppy.messaging import emit_success, emit_system_message, emit_warning
-
- entries = []
- for name in sessions:
- meta_path = base_dir / f"{name}_meta.json"
- try:
- with meta_path.open("r", encoding="utf-8") as meta_file:
- data = json.load(meta_file)
- timestamp = data.get("timestamp")
- message_count = data.get("message_count")
- except Exception:
- timestamp = None
- message_count = None
- entries.append((name, timestamp, message_count))
-
- def sort_key(entry):
- _, timestamp, _ = entry
- if timestamp:
+ removed = []
+ if len(sessions) > max_sessions:
+ to_remove = sessions[: len(sessions) - max_sessions]
+ for p in to_remove:
try:
- return datetime.fromisoformat(timestamp)
- except ValueError:
- return datetime.min
- return datetime.min
-
- entries.sort(key=sort_key, reverse=True)
-
- PAGE_SIZE = 5
- total = len(entries)
- page = 0
-
- def render_page() -> None:
- start = page * PAGE_SIZE
- end = min(start + PAGE_SIZE, total)
- page_entries = entries[start:end]
- emit_system_message("Autosave Sessions Available:")
- for idx, (name, timestamp, message_count) in enumerate(page_entries, start=1):
- timestamp_display = timestamp or "unknown time"
- message_display = (
- f"{message_count} messages"
- if message_count is not None
- else "unknown size"
- )
- emit_system_message(
- f" [{idx}] {name} ({message_display}, saved at {timestamp_display})"
- )
- # If there are more pages, offer next-page; show 'Return to first page' on last page
- if total > PAGE_SIZE:
- page_count = (total + PAGE_SIZE - 1) // PAGE_SIZE
- is_last_page = (page + 1) >= page_count
- remaining = total - (page * PAGE_SIZE + len(page_entries))
- summary = (
- f" and {remaining} more" if (remaining > 0 and not is_last_page) else ""
- )
- next_label = (
- "Return to first page" if is_last_page else f"Next page{summary}"
- )
- emit_system_message(f" [6] {next_label}")
- emit_system_message(" [Enter] Skip loading autosave")
-
- chosen_name: str | None = None
-
- while True:
- render_page()
- try:
- selection = await get_input_with_combined_completion(
- FormattedText(
- [
- (
- "class:prompt",
- "Pick 1-5 to load, 6 for next, or name/Enter: ",
- )
- ]
- )
- )
- except (KeyboardInterrupt, EOFError):
- emit_warning("Autosave selection cancelled")
- return
-
- selection = (selection or "").strip()
- if not selection:
- return
-
- # Numeric choice: 1-5 select within current page; 6 advances page
- if selection.isdigit():
- num = int(selection)
- if num == 6 and total > PAGE_SIZE:
- page = (page + 1) % ((total + PAGE_SIZE - 1) // PAGE_SIZE)
- # loop and re-render next page
- continue
- if 1 <= num <= 5:
- start = page * PAGE_SIZE
- idx = start + (num - 1)
- if 0 <= idx < total:
- chosen_name = entries[idx][0]
- break
- else:
- emit_warning("Invalid selection for this page")
- continue
- emit_warning("Invalid selection; choose 1-5 or 6 for next")
- continue
-
- # Allow direct typing by exact session name
- for name, _ts, _mc in entries:
- if name == selection:
- chosen_name = name
- break
- if chosen_name:
- break
- emit_warning("No autosave loaded (invalid selection)")
- # keep looping and allow another try
-
- if not chosen_name:
- return
+ p.unlink()
+ removed.append(p.stem)
+ except Exception:
+ pass
- try:
- history = load_session(chosen_name, base_dir)
- except FileNotFoundError:
- emit_warning(f"Autosave '{chosen_name}' could not be found")
- return
- except Exception as exc:
- emit_warning(f"Failed to load autosave '{chosen_name}': {exc}")
- return
-
- agent = get_current_agent()
- agent.set_message_history(history)
-
- # Set current autosave session id so subsequent autosaves overwrite this session
- try:
- from code_puppy.config import pin_current_session_name
+ return removed
- pin_current_session_name(chosen_name)
- except Exception:
- pass
-
- total_tokens = sum(agent.estimate_tokens_for_message(msg) for msg in history)
- session_path = base_dir / f"{chosen_name}.pkl"
- emit_success(
- f"✅ Autosave loaded: {len(history)} messages ({total_tokens} tokens)\n"
- f"📁 From: {session_path}"
- )
+async def restore_autosave_interactively(base_dir: Path) -> None:
+ """Deprecated: No-op."""
+ pass
- # Display recent message history for context
- try:
- from code_puppy.command_line.autosave_menu import display_resumed_history
- display_resumed_history(history)
- except Exception:
- pass # Don't fail if display doesn't work in non-TTY environment
+def build_session_paths(base_dir: Path, session_name: str) -> Any:
+ """Deprecated: Returns None or raises."""
+ raise NotImplementedError("Session paths are no longer used.")
diff --git a/code_puppy/tools/command_runner.py b/code_puppy/tools/command_runner.py
index a24495035..90b779208 100644
--- a/code_puppy/tools/command_runner.py
+++ b/code_puppy/tools/command_runner.py
@@ -1,4 +1,5 @@
import asyncio
+import contextvars
import ctypes
import os
import select
@@ -11,6 +12,7 @@
import traceback
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
+from contextvars import ContextVar
from functools import partial
from typing import Callable, List, Literal, Optional, Set
@@ -19,6 +21,7 @@
from rich.text import Text
from code_puppy.callbacks import on_run_shell_command_output
+from code_puppy.config import get_command_timeout_seconds
from code_puppy.messaging import ( # Structured messaging types
AgentReasoningMessage,
ShellOutputMessage,
@@ -106,17 +109,104 @@ def _win32_pipe_has_data(pipe) -> bool:
_AWAITING_USER_INPUT = threading.Event()
-# NOTE: The previous module-level ``_CONFIRMATION_LOCK`` was removed --
-# queueing of parallel approval prompts now lives inside
-# ``get_user_approval_async`` itself, so every caller (shell commands,
-# destructive-command guard, force-push guard, ...) benefits without
-# bolting on their own lock.
+_CONFIRMATION_LOCK = threading.Lock()
# Track running shell processes so we can kill them on Ctrl-C from the UI
_RUNNING_PROCESSES: Set[subprocess.Popen] = set()
_RUNNING_PROCESSES_LOCK = threading.Lock()
_USER_KILLED_PROCESSES = set()
+# Per-session process tracking via ContextVar
+_session_running_processes: ContextVar[Optional[Set[subprocess.Popen]]] = ContextVar(
+ "session_running_processes", default=None
+)
+_session_killed_processes: ContextVar[Optional[Set[int]]] = ContextVar(
+ "session_killed_processes", default=None
+)
+_session_awaiting_input: ContextVar[Optional[threading.Event]] = ContextVar(
+ "session_awaiting_input", default=None
+)
+
+
+def _get_running_processes() -> Set[subprocess.Popen]:
+ """Get the running processes set for the current session context."""
+ session_set = _session_running_processes.get(None)
+ if session_set is not None:
+ return session_set
+ return _RUNNING_PROCESSES
+
+
+def _get_killed_processes() -> Set[int]:
+ """Get the killed processes set for the current session context."""
+ session_set = _session_killed_processes.get(None)
+ if session_set is not None:
+ return session_set
+ return _USER_KILLED_PROCESSES
+
+
+def _get_awaiting_input_event() -> threading.Event:
+ """Get the awaiting-input event for the current session context."""
+ session_evt = _session_awaiting_input.get(None)
+ if session_evt is not None:
+ return session_evt
+ return _AWAITING_USER_INPUT
+
+
+def init_session_process_tracking() -> None:
+ """Initialize per-session process tracking. Call at WS session start."""
+ _session_running_processes.set(set())
+ _session_killed_processes.set(set())
+ _session_awaiting_input.set(threading.Event())
+ _session_active_stop_events.set(set())
+ _session_keyboard_refcount.set(0)
+ _session_ctrl_x_stop_event.set(None)
+ _session_ctrl_x_thread.set(None)
+
+
+def cleanup_session_process_tracking() -> None:
+ """Tear down per-session process tracking. Call at WS session end.
+
+ Kills any still-running session processes, then clears the per-session
+ process and killed-process sets to release any held Popen references.
+ """
+ # Kill any still-running session processes first
+ session_procs = _session_running_processes.get(None)
+ if session_procs is not None:
+ for p in list(session_procs):
+ try:
+ if p.poll() is None:
+ _kill_process_group(p)
+ except Exception:
+ pass
+ session_procs.clear()
+ session_killed = _session_killed_processes.get(None)
+ if session_killed is not None:
+ session_killed.clear()
+ session_stop = _session_active_stop_events.get(None)
+ if session_stop is not None:
+ for evt in session_stop:
+ evt.set() # Signal before discarding!
+ session_stop.clear()
+ # Clean up keyboard context
+ session_ctrl_x = _session_ctrl_x_stop_event.get(None)
+ if session_ctrl_x is not None:
+ session_ctrl_x.set() # Signal stop
+ session_thread = _session_ctrl_x_thread.get(None)
+ if session_thread is not None and session_thread.is_alive():
+ try:
+ session_thread.join(timeout=0.2)
+ except Exception:
+ pass
+ _session_keyboard_refcount.set(None)
+ _session_ctrl_x_stop_event.set(None)
+ _session_ctrl_x_thread.set(None)
+ # Reset to None so subsequent calls fall back to global
+ _session_running_processes.set(None)
+ _session_killed_processes.set(None)
+ _session_awaiting_input.set(None)
+ _session_active_stop_events.set(None)
+
+
# Global state for shell command keyboard handling
_SHELL_CTRL_X_STOP_EVENT: Optional[threading.Event] = None
_SHELL_CTRL_X_THREAD: Optional[threading.Thread] = None
@@ -138,10 +228,82 @@ def _win32_pipe_has_data(pipe) -> bool:
_KEYBOARD_CONTEXT_REFCOUNT = 0
_KEYBOARD_CONTEXT_LOCK = threading.Lock()
+# Per-session keyboard context refcount (ContextVar for WS session isolation)
+_session_keyboard_refcount: ContextVar[Optional[int]] = ContextVar(
+ "session_keyboard_refcount", default=None
+)
+_session_ctrl_x_stop_event: ContextVar[Optional[threading.Event]] = ContextVar(
+ "session_ctrl_x_stop_event", default=None
+)
+_session_ctrl_x_thread: ContextVar[Optional[threading.Thread]] = ContextVar(
+ "session_ctrl_x_thread", default=None
+)
+
# Thread-safe registry of active stop events for concurrent shell commands
_ACTIVE_STOP_EVENTS: Set[threading.Event] = set()
_ACTIVE_STOP_EVENTS_LOCK = threading.Lock()
+# Per-session stop events (ContextVar for session isolation)
+_session_active_stop_events: ContextVar[Optional[Set[threading.Event]]] = ContextVar(
+ "session_active_stop_events", default=None
+)
+
+
+def _get_keyboard_refcount() -> int:
+ """Get keyboard context refcount for the current session."""
+ session_val = _session_keyboard_refcount.get(None)
+ if session_val is not None:
+ return session_val
+ return _KEYBOARD_CONTEXT_REFCOUNT
+
+
+def _set_keyboard_refcount(value: int) -> None:
+ """Set keyboard context refcount for the current session."""
+ if _session_keyboard_refcount.get(None) is not None:
+ _session_keyboard_refcount.set(value)
+ else:
+ global _KEYBOARD_CONTEXT_REFCOUNT
+ _KEYBOARD_CONTEXT_REFCOUNT = value
+
+
+def _get_ctrl_x_stop_event() -> Optional[threading.Event]:
+ """Get Ctrl+X stop event for the current session."""
+ session_evt = _session_ctrl_x_stop_event.get(None)
+ if session_evt is not None:
+ return session_evt
+ return _SHELL_CTRL_X_STOP_EVENT
+
+
+def _get_ctrl_x_thread() -> Optional[threading.Thread]:
+ """Get Ctrl+X thread for the current session."""
+ session_thread = _session_ctrl_x_thread.get(None)
+ if session_thread is not None:
+ return session_thread
+ return _SHELL_CTRL_X_THREAD
+
+
+def _get_active_stop_events() -> Set[threading.Event]:
+ """Get the active stop events set for the current session context."""
+ session_set = _session_active_stop_events.get(None)
+ if session_set is not None:
+ return session_set
+ return _ACTIVE_STOP_EVENTS
+
+
+@contextmanager
+def _guarded_set(collection, global_ref, lock):
+ """Acquire *lock* only when *collection* is the shared global *global_ref*.
+
+ Per-session sets (ContextVar-backed) are single-writer within their
+ asyncio task context and don't need locking.
+ """
+ if collection is global_ref:
+ with lock:
+ yield collection
+ else:
+ yield collection
+
+
# Mid-flight backgrounding (Ctrl+X Ctrl+B) machinery lives in
# ``shell_backgrounding`` (600-line cap); re-exported here because the
# chord handler and the streaming pumps are the consumers.
@@ -153,13 +315,15 @@ def _win32_pipe_has_data(pipe) -> bool:
def _register_process(proc: subprocess.Popen) -> None:
- with _RUNNING_PROCESSES_LOCK:
- _RUNNING_PROCESSES.add(proc)
+ procs = _get_running_processes()
+ with _guarded_set(procs, _RUNNING_PROCESSES, _RUNNING_PROCESSES_LOCK):
+ procs.add(proc)
def _unregister_process(proc: subprocess.Popen) -> None:
- with _RUNNING_PROCESSES_LOCK:
- _RUNNING_PROCESSES.discard(proc)
+ procs = _get_running_processes()
+ with _guarded_set(procs, _RUNNING_PROCESSES, _RUNNING_PROCESSES_LOCK):
+ procs.discard(proc)
def _kill_process_group(proc: subprocess.Popen) -> None:
@@ -244,13 +408,16 @@ def kill_all_running_shell_processes() -> int:
Returns the number of processes signaled.
"""
# Signal all active reader threads to stop
- with _ACTIVE_STOP_EVENTS_LOCK:
- for evt in _ACTIVE_STOP_EVENTS:
+ active_stop = _get_active_stop_events()
+ with _guarded_set(active_stop, _ACTIVE_STOP_EVENTS, _ACTIVE_STOP_EVENTS_LOCK):
+ for evt in active_stop:
evt.set()
procs: list[subprocess.Popen]
- with _RUNNING_PROCESSES_LOCK:
- procs = list(_RUNNING_PROCESSES)
+ running = _get_running_processes()
+ killed = _get_killed_processes()
+ with _guarded_set(running, _RUNNING_PROCESSES, _RUNNING_PROCESSES_LOCK):
+ procs = list(running)
count = 0
for p in procs:
try:
@@ -268,7 +435,7 @@ def kill_all_running_shell_processes() -> int:
if p.poll() is None:
_kill_process_group(p)
count += 1
- _USER_KILLED_PROCESSES.add(p.pid)
+ killed.add(p.pid)
finally:
_unregister_process(p)
return count
@@ -276,23 +443,24 @@ def kill_all_running_shell_processes() -> int:
def get_running_shell_process_count() -> int:
"""Return the number of currently-active shell processes being tracked."""
- with _RUNNING_PROCESSES_LOCK:
+ running = _get_running_processes()
+ with _guarded_set(running, _RUNNING_PROCESSES, _RUNNING_PROCESSES_LOCK):
alive = 0
stale: Set[subprocess.Popen] = set()
- for proc in _RUNNING_PROCESSES:
+ for proc in running:
if proc.poll() is None:
alive += 1
else:
stale.add(proc)
for proc in stale:
- _RUNNING_PROCESSES.discard(proc)
+ running.discard(proc)
return alive
# Function to check if user input is awaited
def is_awaiting_user_input():
"""Check if command_runner is waiting for user input."""
- return _AWAITING_USER_INPUT.is_set()
+ return _get_awaiting_input_event().is_set()
# Function to set user input flag
@@ -313,9 +481,10 @@ def set_awaiting_user_input(awaiting=True):
to guess from the screen).
"""
if awaiting:
- _AWAITING_USER_INPUT.set()
+ _get_awaiting_input_event().set()
else:
- _AWAITING_USER_INPUT.clear()
+ _get_awaiting_input_event().clear()
+
# Best-effort notification; never let an observer disturb the prompt path.
try:
from code_puppy.callbacks import on_awaiting_user_input
@@ -679,15 +848,14 @@ def run_shell_command_streaming(
silent: bool = False,
):
stop_event = threading.Event()
- with _ACTIVE_STOP_EVENTS_LOCK:
- _ACTIVE_STOP_EVENTS.add(stop_event)
+ active_stop = _get_active_stop_events()
+ with _guarded_set(active_stop, _ACTIVE_STOP_EVENTS, _ACTIVE_STOP_EVENTS_LOCK):
+ active_stop.add(stop_event)
start_time = time.time()
last_output_time = [start_time]
# Get the user-configured absolute timeout for shell commands
- from code_puppy.config import get_command_timeout_seconds
-
ABSOLUTE_TIMEOUT_SECONDS = get_command_timeout_seconds()
stdout_lines = []
@@ -1023,8 +1191,9 @@ def detach_to_background():
)
get_message_bus().emit(shell_output_msg)
- with _ACTIVE_STOP_EVENTS_LOCK:
- _ACTIVE_STOP_EVENTS.discard(stop_event)
+ active_stop = _get_active_stop_events()
+ with _guarded_set(active_stop, _ACTIVE_STOP_EVENTS, _ACTIVE_STOP_EVENTS_LOCK):
+ active_stop.discard(stop_event)
if exit_code != 0:
time.sleep(1)
@@ -1038,7 +1207,7 @@ def detach_to_background():
exit_code=exit_code,
execution_time=execution_time,
timeout=False,
- user_interrupted=process.pid in _USER_KILLED_PROCESSES,
+ user_interrupted=process.pid in _get_killed_processes(),
)
return ShellCommandOutput(
@@ -1052,8 +1221,9 @@ def detach_to_background():
)
except Exception as e:
- with _ACTIVE_STOP_EVENTS_LOCK:
- _ACTIVE_STOP_EVENTS.discard(stop_event)
+ active_stop = _get_active_stop_events()
+ with _guarded_set(active_stop, _ACTIVE_STOP_EVENTS, _ACTIVE_STOP_EVENTS_LOCK):
+ active_stop.discard(stop_event)
return ShellCommandOutput(
success=False,
command=command,
@@ -1072,6 +1242,20 @@ async def run_shell_command(
timeout: int = 60,
background: bool = False,
) -> ShellCommandOutput:
+ # Resolve CWD from the session ContextVar when not explicitly provided.
+ # This supports concurrent WebSocket sessions where each session has its own CWD
+ # without relying on the process-global os.chdir().
+ # Returns None for CLI sessions (subprocess inherits process CWD as normal).
+ if cwd is None:
+ try:
+ from code_puppy.plugins.walmart_specific.session_context import (
+ get_session_working_directory,
+ )
+
+ cwd = get_session_working_directory()
+ except ImportError:
+ pass
+
# Generate unique group_id for this command execution
group_id = generate_group_id("shell_command", command)
@@ -1204,12 +1388,32 @@ async def run_shell_command(
# Check if we're running as a sub-agent (skip confirmation and run silently)
running_as_subagent = is_subagent()
+ confirmation_lock_acquired = False
+
+ # Check if WebSocket mode is active (permission handled via WebSocket callbacks)
+ websocket_mode_active = False
+ try:
+ from code_puppy.api.permission_plugin import get_websocket_context
+
+ websocket_mode_active = get_websocket_context() is not None
+ except (ImportError, AttributeError):
+ pass
+
# Only ask for confirmation if we're in an interactive TTY, not in yolo mode,
- # and NOT running as a sub-agent (sub-agents run without user interaction)
- if not yolo_mode and not running_as_subagent and sys.stdin.isatty():
- # No local lock needed -- get_user_approval_async serializes
- # parallel prompts internally so the 2nd, 3rd, 4th... destructive
- # commands queue up cleanly instead of vanishing.
+ # NOT running as a sub-agent, and NOT in WebSocket mode (WebSocket has its own permission system)
+ if (
+ not yolo_mode
+ and not running_as_subagent
+ and not websocket_mode_active
+ and sys.stdin.isatty()
+ ):
+ confirmation_lock_acquired = _CONFIRMATION_LOCK.acquire(blocking=False)
+ if not confirmation_lock_acquired:
+ return ShellCommandOutput(
+ success=False,
+ command=command,
+ error="Another command is currently awaiting confirmation",
+ )
# Get puppy name for personalized messages
from code_puppy.config import get_puppy_name
@@ -1227,8 +1431,7 @@ async def run_shell_command(
panel_content.append("📂 Working directory: ", style="dim")
panel_content.append(cwd, style="dim cyan")
- # Use the common approval function (async version).
- # Internal queueing means parallel calls wait their turn here.
+ # Use the common approval function (async version)
confirmed, user_feedback = await get_user_approval_async(
title="Shell Command",
content=panel_content,
@@ -1237,6 +1440,10 @@ async def run_shell_command(
puppy_name=puppy_name,
)
+ # Release lock after approval
+ if confirmation_lock_acquired:
+ _CONFIRMATION_LOCK.release()
+
if not confirmed:
if user_feedback:
result = ShellCommandOutput(
@@ -1455,9 +1662,13 @@ async def _run_command_inner(
try:
# Run the blocking shell command in a thread pool to avoid blocking the event loop
# This allows multiple sub-agents to run shell commands in parallel
+ # Copy context so ContextVar-based session tracking propagates to the worker thread
+ ctx = contextvars.copy_context()
return await loop.run_in_executor(
_SHELL_EXECUTOR,
- partial(_run_command_sync, command, cwd, timeout, group_id, silent),
+ partial(
+ ctx.run, _run_command_sync, command, cwd, timeout, group_id, silent
+ ),
)
except Exception as e:
if not silent:
diff --git a/pyproject.toml b/pyproject.toml
index c020c3847..ba5ae44a4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -9,6 +9,11 @@ description = "Code generation agent"
readme = "README.md"
requires-python = ">=3.11,<3.15"
dependencies = [
+ "aiosqlite>=0.22.1",
+ "websockets>=12.0",
+ "uvicorn[standard]>=0.27.0",
+ "fastapi>=0.109.0",
+ "dbos>=2.11.0",
"pydantic-ai-slim[openai,anthropic,mcp]==1.56.0",
"typer>=0.12.0",
"mcp>=1.9.4",
@@ -64,6 +69,7 @@ HomePage = "https://github.com/mpfaffenberger/code_puppy"
[project.scripts]
code-puppy = "code_puppy.main:main_entry"
pup = "code_puppy.main:main_entry"
+code-puppy-api = "code_puppy.api.main:main"
[tool.logfire]
ignore_no_config = true
From 2e03e1d46f0134e91e04daeaeebda70f9c427e19 Mon Sep 17 00:00:00 2001
From: shravya maddipudi
Date: Mon, 1 Jun 2026 13:56:44 -0500
Subject: [PATCH 10/29] feat(ws): adapt complete responses to streaming frames
---
.../test_streaming_protocol_transform.py | 93 +++++++++++++++
code_puppy/api/ws/chat_handler.py | 108 +++++++++++++++---
code_puppy/api/ws/schemas.py | 3 +
3 files changed, 186 insertions(+), 18 deletions(-)
create mode 100644 code_puppy/api/tests/test_streaming_protocol_transform.py
diff --git a/code_puppy/api/tests/test_streaming_protocol_transform.py b/code_puppy/api/tests/test_streaming_protocol_transform.py
new file mode 100644
index 000000000..a70d3f6cd
--- /dev/null
+++ b/code_puppy/api/tests/test_streaming_protocol_transform.py
@@ -0,0 +1,93 @@
+"""Tests for streaming-only assistant response protocol adaptation."""
+
+from code_puppy.api.ws.chat_handler import build_assistant_text_stream_frames
+
+
+def _dump_frames(**kwargs):
+ return [
+ frame.model_dump(exclude_none=True)
+ for frame in build_assistant_text_stream_frames(**kwargs)
+ ]
+
+
+def test_complete_response_is_adapted_to_streaming_frames():
+ frames = _dump_frames(
+ response_text="hello from a non-streaming model",
+ session_id="WS_session_test",
+ agent_name="code-puppy",
+ model_name="non-stream-model",
+ tokens={"total_tokens": 7},
+ message_id="msg-fixed",
+ timestamp=123.456,
+ )
+
+ assert [frame["type"] for frame in frames] == [
+ "assistant_message_start",
+ "assistant_message_delta",
+ "assistant_message_end",
+ "stream_end",
+ ]
+
+ start, delta, end, stream_end = frames
+ assert start == {
+ "type": "assistant_message_start",
+ "message_id": "msg-fixed",
+ "part_type": "text",
+ "part_index": 0,
+ "timestamp": 123.456,
+ "session_id": "WS_session_test",
+ "agent_name": "code-puppy",
+ "model_name": "non-stream-model",
+ }
+ assert delta["message_id"] == "msg-fixed"
+ assert delta["content"] == "hello from a non-streaming model"
+ assert delta["part_index"] == 0
+ assert delta["session_id"] == "WS_session_test"
+
+ assert end["message_id"] == "msg-fixed"
+ assert end["part_type"] == "text"
+ assert end["full_content"] == "hello from a non-streaming model"
+ assert end["timestamp"] == 123.456
+
+ assert stream_end == {
+ "type": "stream_end",
+ "success": True,
+ "session_id": "WS_session_test",
+ "total_length": len("hello from a non-streaming model"),
+ "agent_name": "code-puppy",
+ "model_name": "non-stream-model",
+ "tokens": {"total_tokens": 7},
+ }
+
+
+def test_complete_response_transform_does_not_emit_legacy_response_frame():
+ frames = _dump_frames(
+ response_text="render me through streaming",
+ session_id="WS_session_test",
+ message_id="msg-fixed",
+ timestamp=1.0,
+ )
+
+ assert "response" not in {frame["type"] for frame in frames}
+ assert any(
+ frame["type"] == "assistant_message_delta"
+ and frame["content"] == "render me through streaming"
+ for frame in frames
+ )
+
+
+def test_complete_response_transform_preserves_part_type_for_thinking_parts():
+ frames = _dump_frames(
+ response_text="reasoning trace",
+ session_id="WS_session_test",
+ part_type="thinking",
+ part_index=1,
+ message_id="thinking-fixed",
+ timestamp=2.0,
+ )
+
+ assert frames[0]["part_type"] == "thinking"
+ assert frames[1]["part_index"] == 1
+ assert frames[2]["part_type"] == "thinking"
+ assert frames[2]["part_index"] == 1
+ assert frames[3]["type"] == "stream_end"
diff --git a/code_puppy/api/ws/chat_handler.py b/code_puppy/api/ws/chat_handler.py
index fbcb4c918..c8af429a7 100644
--- a/code_puppy/api/ws/chat_handler.py
+++ b/code_puppy/api/ws/chat_handler.py
@@ -53,7 +53,6 @@
ServerConfigValue,
ServerError,
ServerMessage,
- ServerResponse,
ServerSessionMetaUpdated,
ServerSessionRestored,
ServerSessionSwitched,
@@ -201,6 +200,81 @@ def build_error_response_frames(
return frames
+def build_assistant_text_stream_frames(
+ *,
+ response_text: str,
+ session_id: str,
+ agent_name: str | None = None,
+ model_name: str | None = None,
+ tokens: dict[str, Any] | None = None,
+ part_type: str = "text",
+ part_index: int = 0,
+ message_id: str | None = None,
+ timestamp: float | None = None,
+) -> list[
+ ServerAssistantMessageStart
+ | ServerAssistantMessageDelta
+ | ServerAssistantMessageEnd
+ | ServerStreamEnd
+]:
+ """Represent a complete assistant response using streaming-shaped frames.
+
+ The GUI protocol is streaming-only: assistant text must be rendered from
+ ``assistant_message_start``/``assistant_message_delta``/
+ ``assistant_message_end`` and finalized by ``stream_end``. Some upstream
+ model clients return a complete response rather than true token deltas; this
+ helper adapts those complete responses to the same wire shape so the GUI has
+ one rendering path.
+
+ This intentionally emits a single full-content delta for non-streaming
+ upstream responses. Artificial local chunking can be added later without
+ changing the protocol contract.
+ """
+ import time as time_module
+
+ now = timestamp if timestamp is not None else time_module.time()
+ msg_id = message_id or f"msg-{session_id}-{uuid.uuid4().hex}"
+ content = response_text or ""
+
+ return [
+ ServerAssistantMessageStart(
+ message_id=msg_id,
+ part_type=part_type,
+ part_index=part_index,
+ timestamp=now,
+ session_id=session_id,
+ agent_name=agent_name,
+ model_name=model_name,
+ ),
+ ServerAssistantMessageDelta(
+ message_id=msg_id,
+ content=content,
+ part_index=part_index,
+ session_id=session_id,
+ agent_name=agent_name,
+ model_name=model_name,
+ ),
+ ServerAssistantMessageEnd(
+ message_id=msg_id,
+ part_type=part_type,
+ part_index=part_index,
+ full_content=content,
+ timestamp=now,
+ session_id=session_id,
+ agent_name=agent_name,
+ model_name=model_name,
+ ),
+ ServerStreamEnd(
+ success=True,
+ total_length=len(content),
+ session_id=session_id,
+ agent_name=agent_name,
+ model_name=model_name,
+ tokens=tokens,
+ ),
+ ]
+
+
def register_chat_endpoint(app: FastAPI) -> None:
"""Register the /ws/chat WebSocket endpoint."""
@@ -229,8 +303,8 @@ async def websocket_chat(
{"type": "tool_result", "tool_name": "...", "result": "...", "success": true,
"agent_name": "...", "model_name": "..."}
- # Final response
- {"type": "response", "content": "...", "done": true,
+ # Final stream marker
+ {"type": "stream_end", "success": true, "total_length": 123,
"agent_name": "...", "model_name": "...", "tokens": {...}}
# Errors
@@ -3268,21 +3342,19 @@ async def drain_events_with_signal():
f"Sent thinking content ({len(thinking_text)} chars) for non-streaming model"
)
- # Send the main response
- await send_typed(
- ServerResponse(
- content=response_text,
- done=True,
- session_id=session_id,
- agent_name=agent.name
- if agent
- else "code-puppy",
- model_name=agent.get_model_name()
- if agent
- else "unknown",
- tokens=tokens_used,
- )
- )
+ # Adapt complete non-streaming upstream responses into
+ # the streaming-only GUI protocol. Assistant text is no
+ # longer delivered via legacy `response.content`.
+ for frame in build_assistant_text_stream_frames(
+ response_text=response_text,
+ session_id=session_id,
+ agent_name=agent.name if agent else "code-puppy",
+ model_name=agent.get_model_name()
+ if agent
+ else "unknown",
+ tokens=tokens_used,
+ ):
+ await send_typed(frame)
else:
# B1 streaming: extract real tool results BEFORE stream_end
# so the frontend session store is still alive when they arrive.
diff --git a/code_puppy/api/ws/schemas.py b/code_puppy/api/ws/schemas.py
index 05e56528d..f7a198e65 100644
--- a/code_puppy/api/ws/schemas.py
+++ b/code_puppy/api/ws/schemas.py
@@ -385,10 +385,13 @@ class ServerAssistantMessageEnd(_BaseMessage):
"""Marks the end of a streaming assistant message part.
``full_content`` contains the complete accumulated text for the part.
+ ``part_type`` mirrors ``assistant_message_start`` for GUI reducers that
+ route text/thinking/tool-call parts consistently.
"""
type: Literal["assistant_message_end"] = "assistant_message_end"
message_id: str
+ part_type: Optional[str] = None
part_index: int
full_content: str
timestamp: float
From ffdc3e0cc6bc6eea9d2fb403fd47b65e06a3b8d9 Mon Sep 17 00:00:00 2001
From: shravya maddipudi
Date: Mon, 1 Jun 2026 13:56:44 -0500
Subject: [PATCH 11/29] refactor(api): remove terminal and sessions server
routes
---
code_puppy/api/app.py | 46 +-
code_puppy/api/pty_manager.py | 453 ------------------
code_puppy/api/templates/terminal.html | 382 ---------------
.../tests/test_gate2_legacy_ws_namespace.py | 42 ++
code_puppy/api/websocket.py | 8 +-
code_puppy/api/ws/__init__.py | 6 -
code_puppy/api/ws/terminal_handler.py | 141 ------
7 files changed, 47 insertions(+), 1031 deletions(-)
delete mode 100644 code_puppy/api/pty_manager.py
delete mode 100644 code_puppy/api/templates/terminal.html
create mode 100644 code_puppy/api/tests/test_gate2_legacy_ws_namespace.py
delete mode 100644 code_puppy/api/ws/terminal_handler.py
diff --git a/code_puppy/api/app.py b/code_puppy/api/app.py
index a68b86c14..8624db6fc 100644
--- a/code_puppy/api/app.py
+++ b/code_puppy/api/app.py
@@ -110,17 +110,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# Shutdown: clean up all the things!
logger.info("🐶 Code Puppy API shutting down, cleaning up...")
- # 1. Close all PTY sessions
- try:
- from code_puppy.api.pty_manager import get_pty_manager
-
- pty_manager = get_pty_manager()
- await pty_manager.close_all()
- logger.info("✓ All PTY sessions closed")
- except Exception as e:
- logger.error("Error closing PTY sessions: %s", e)
-
- # 2. Shutdown session cache thread pool executor
+ # 1. Shutdown session cache thread pool executor
try:
from code_puppy.api.session_cache import shutdown_executor
@@ -129,7 +119,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
except Exception as e:
logger.error("Error shutting down session cache executor: %s", e)
- # 3. Shutdown ws_sessions thread pool executor
+ # 2. Shutdown ws_sessions thread pool executor
try:
from code_puppy.api.routers import ws_sessions
@@ -215,7 +205,7 @@ def create_app() -> FastAPI:
app.include_router(protocol.router, prefix="/api/protocol", tags=["protocol"])
- # WebSocket endpoints (events + terminal)
+ # WebSocket endpoints
from code_puppy.api.websocket import setup_websocket
setup_websocket(app)
@@ -239,21 +229,15 @@ async def root():
🐶
Code Puppy
- WebSocket: ws://localhost:8765/ws/terminal
+ WebSocket: ws://localhost:8765/ws/chat
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -261,28 +245,6 @@ async def root():
"""
)
- @app.get("/terminal")
- async def terminal_page():
- """Serve the interactive terminal page."""
- html_file = templates_dir / "terminal.html"
- if html_file.exists():
- return FileResponse(html_file, media_type="text/html")
- return HTMLResponse(
- content="
",
- status_code=404,
- )
-
- @app.get("/sessions")
- async def sessions_page():
- """Serve the sessions monitoring page."""
- html_file = templates_dir / "sessions.html"
- if html_file.exists():
- return FileResponse(html_file, media_type="text/html")
- return HTMLResponse(
- content="
",
- status_code=404,
- )
-
@app.get("/chat")
async def chat_page():
"""Serve the chat interface page."""
diff --git a/code_puppy/api/pty_manager.py b/code_puppy/api/pty_manager.py
deleted file mode 100644
index d57dacdc8..000000000
--- a/code_puppy/api/pty_manager.py
+++ /dev/null
@@ -1,453 +0,0 @@
-"""PTY Manager for terminal emulation with cross-platform support.
-
-Provides pseudo-terminal (PTY) functionality for interactive shell sessions
-via WebSocket connections. Supports Unix (pty module) and Windows (pywinpty).
-"""
-
-import asyncio
-import logging
-import os
-import signal
-import struct
-import sys
-from dataclasses import dataclass, field
-from typing import Any, Callable, Optional
-
-logger = logging.getLogger(__name__)
-
-# Platform detection
-IS_WINDOWS = sys.platform == "win32"
-
-# Conditional imports based on platform
-if IS_WINDOWS:
- try:
- import winpty # type: ignore
-
- HAS_WINPTY = True
- except ImportError:
- HAS_WINPTY = False
- winpty = None
-else:
- import fcntl
- import pty
- import termios
-
- HAS_WINPTY = False
-
-
-@dataclass
-class PTYSession:
- """Represents an active PTY session."""
-
- session_id: str
- master_fd: Optional[int] = None # Unix only
- slave_fd: Optional[int] = None # Unix only
- pid: Optional[int] = None # Unix only
- winpty_process: Any = None # Windows only
- cols: int = 80
- rows: int = 24
- on_output: Optional[Callable[[bytes], None]] = None
- _reader_task: Optional[asyncio.Task] = None # type: ignore
- _running: bool = field(default=False, init=False)
-
- def is_alive(self) -> bool:
- """Check if the PTY session is still active."""
- if IS_WINDOWS:
- return self.winpty_process is not None and self.winpty_process.isalive()
- else:
- if self.pid is None:
- return False
- try:
- pid_result, status = os.waitpid(self.pid, os.WNOHANG)
- if pid_result == 0:
- return True # Still running
- return False # Exited
- except ChildProcessError:
- return False # Already reaped
-
-
-class PTYManager:
- """Manages PTY sessions for terminal emulation.
-
- Provides cross-platform terminal emulation with support for:
- - Unix systems via the pty module
- - Windows via pywinpty (optional dependency)
-
- Example:
- manager = PTYManager()
- session = await manager.create_session(
- session_id="my-terminal",
- on_output=lambda data: print(data.decode())
- )
- await manager.write(session.session_id, b"ls -la\n")
- await manager.close_session(session.session_id)
- """
-
- def __init__(self) -> None:
- self._sessions: dict[str, PTYSession] = {}
- self._lock = asyncio.Lock()
-
- @property
- def sessions(self) -> dict[str, PTYSession]:
- """Get all active sessions."""
- return self._sessions.copy()
-
- async def create_session(
- self,
- session_id: str,
- cols: int = 80,
- rows: int = 24,
- on_output: Optional[Callable[[bytes], None]] = None,
- shell: Optional[str] = None,
- ) -> PTYSession:
- """Create a new PTY session.
-
- Args:
- session_id: Unique identifier for the session
- cols: Terminal width in columns
- rows: Terminal height in rows
- on_output: Callback for terminal output
- shell: Shell to spawn (defaults to user's shell or /bin/bash)
-
- Returns:
- PTYSession: The created session
-
- Raises:
- RuntimeError: If session creation fails
- """
- async with self._lock:
- if session_id in self._sessions:
- logger.warning(f"Session {session_id} already exists, closing old one")
- await self._close_session_internal(session_id)
-
- if IS_WINDOWS:
- session = await self._create_windows_session(
- session_id, cols, rows, on_output, shell
- )
- else:
- session = await self._create_unix_session(
- session_id, cols, rows, on_output, shell
- )
-
- self._sessions[session_id] = session
- logger.info(f"Created PTY session: {session_id}")
- return session
-
- async def _create_unix_session(
- self,
- session_id: str,
- cols: int,
- rows: int,
- on_output: Optional[Callable[[bytes], None]],
- shell: Optional[str],
- ) -> PTYSession:
- """Create a PTY session on Unix systems."""
- shell = shell or os.environ.get("SHELL", "/bin/bash")
-
- # Fork a new process with a PTY
- pid, master_fd = pty.fork()
-
- if pid == 0:
- # Child process - exec the shell
- os.execlp(shell, shell, "-i") # noqa: S606
- else:
- # Parent process
- # Set terminal size
- self._set_unix_winsize(master_fd, rows, cols)
-
- # Make master_fd non-blocking
- flags = fcntl.fcntl(master_fd, fcntl.F_GETFL)
- fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
-
- session = PTYSession(
- session_id=session_id,
- master_fd=master_fd,
- pid=pid,
- cols=cols,
- rows=rows,
- on_output=on_output,
- )
- session._running = True
-
- # Start reader task
- session._reader_task = asyncio.create_task(self._unix_reader_loop(session))
-
- return session
-
- async def _create_windows_session(
- self,
- session_id: str,
- cols: int,
- rows: int,
- on_output: Optional[Callable[[bytes], None]],
- shell: Optional[str],
- ) -> PTYSession:
- """Create a PTY session on Windows systems."""
- if not HAS_WINPTY:
- raise RuntimeError(
- "pywinpty is required for Windows terminal support. "
- "Install it with: pip install pywinpty"
- )
-
- shell = shell or os.environ.get("COMSPEC", "cmd.exe")
-
- # Create winpty process
- winpty_process = winpty.PtyProcess.spawn(
- shell,
- dimensions=(rows, cols),
- )
-
- session = PTYSession(
- session_id=session_id,
- winpty_process=winpty_process,
- cols=cols,
- rows=rows,
- on_output=on_output,
- )
- session._running = True
-
- # Start reader task
- session._reader_task = asyncio.create_task(self._windows_reader_loop(session))
-
- return session
-
- def _set_unix_winsize(self, fd: int, rows: int, cols: int) -> None:
- """Set the terminal window size on Unix."""
- winsize = struct.pack("HHHH", rows, cols, 0, 0)
- fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
-
- async def _unix_reader_loop(self, session: PTYSession) -> None:
- """Read output from Unix PTY and forward to callback."""
- loop = asyncio.get_running_loop()
-
- try:
- while session._running and session.master_fd is not None:
- try:
- data = await loop.run_in_executor(
- None, self._read_unix_pty, session.master_fd
- )
-
- if data is None:
- # No data available, wait a bit
- await asyncio.sleep(0.01)
- continue
- elif data == b"":
- # EOF - process terminated
- break
- elif session.on_output:
- session.on_output(data)
-
- except asyncio.CancelledError:
- break
-
- except Exception as e:
- logger.error(f"Unix reader loop error: {e}")
- finally:
- session._running = False
-
- def _read_unix_pty(self, fd: int) -> bytes | None:
- """Read from Unix PTY file descriptor.
-
- Returns:
- bytes: Data read from PTY
- None: No data available (would block)
- b'': EOF (process terminated)
- """
- try:
- data = os.read(fd, 4096)
- return data
- except BlockingIOError:
- return None
- except OSError:
- return b""
-
- async def _windows_reader_loop(self, session: PTYSession) -> None:
- """Read output from Windows PTY and forward to callback."""
- loop = asyncio.get_running_loop()
-
- try:
- while (
- session._running
- and session.winpty_process is not None
- and session.winpty_process.isalive()
- ):
- try:
- data = await loop.run_in_executor(
- None, session.winpty_process.read, 4096
- )
- if data and session.on_output:
- session.on_output(
- data.encode() if isinstance(data, str) else data
- )
- except EOFError:
- break
- except asyncio.CancelledError:
- break
-
- await asyncio.sleep(0.01)
-
- except Exception as e:
- logger.error(f"Windows reader loop error: {e}")
- finally:
- session._running = False
-
- async def write(self, session_id: str, data: bytes) -> bool:
- """Write data to a PTY session.
-
- Args:
- session_id: The session to write to
- data: Data to write
-
- Returns:
- bool: True if write succeeded
- """
- session = self._sessions.get(session_id)
- if not session:
- logger.warning(f"Session {session_id} not found")
- return False
-
- try:
- if IS_WINDOWS:
- if session.winpty_process:
- session.winpty_process.write(
- data.decode() if isinstance(data, bytes) else data
- )
- return True
- else:
- if session.master_fd is not None:
- os.write(session.master_fd, data)
- return True
- except Exception as e:
- logger.error(f"Write error for session {session_id}: {e}")
-
- return False
-
- async def resize(self, session_id: str, cols: int, rows: int) -> bool:
- """Resize a PTY session.
-
- Args:
- session_id: The session to resize
- cols: New width in columns
- rows: New height in rows
-
- Returns:
- bool: True if resize succeeded
- """
- session = self._sessions.get(session_id)
- if not session:
- logger.warning(f"Session {session_id} not found")
- return False
-
- try:
- if IS_WINDOWS:
- if session.winpty_process:
- session.winpty_process.setwinsize(rows, cols)
- else:
- if session.master_fd is not None:
- self._set_unix_winsize(session.master_fd, rows, cols)
-
- session.cols = cols
- session.rows = rows
- logger.debug(f"Resized session {session_id} to {cols}x{rows}")
- return True
-
- except Exception as e:
- logger.error(f"Resize error for session {session_id}: {e}")
- return False
-
- async def close_session(self, session_id: str) -> bool:
- """Close a PTY session.
-
- Args:
- session_id: The session to close
-
- Returns:
- bool: True if session was closed
- """
- async with self._lock:
- return await self._close_session_internal(session_id)
-
- async def _close_session_internal(self, session_id: str) -> bool:
- """Internal session close without lock."""
- session = self._sessions.pop(session_id, None)
- if not session:
- return False
-
- session._running = False
-
- # Cancel reader task
- if session._reader_task:
- session._reader_task.cancel()
- try:
- await session._reader_task
- except asyncio.CancelledError:
- pass
-
- # Clean up platform-specific resources
- if IS_WINDOWS:
- if session.winpty_process:
- try:
- session.winpty_process.terminate()
- except Exception as e:
- logger.debug(f"Error terminating winpty: {e}")
- else:
- # Close file descriptors
- if session.master_fd is not None:
- try:
- os.close(session.master_fd)
- except OSError:
- pass
-
- # Terminate child process
- if session.pid is not None:
- try:
- os.kill(session.pid, signal.SIGTERM)
- # Use WNOHANG to avoid blocking the event loop
- try:
- os.waitpid(session.pid, os.WNOHANG)
- except ChildProcessError:
- pass
- except (OSError, ChildProcessError):
- pass
-
- logger.info(f"Closed PTY session: {session_id}")
- return True
-
- async def close_all(self) -> None:
- """Close all PTY sessions."""
- async with self._lock:
- session_ids = list(self._sessions.keys())
- for session_id in session_ids:
- await self._close_session_internal(session_id)
- logger.info("Closed all PTY sessions")
-
- def get_session(self, session_id: str) -> Optional[PTYSession]:
- """Get a session by ID.
-
- Args:
- session_id: The session ID
-
- Returns:
- PTYSession or None if not found
- """
- return self._sessions.get(session_id)
-
- def list_sessions(self) -> list[str]:
- """List all active session IDs.
-
- Returns:
- List of session IDs
- """
- return list(self._sessions.keys())
-
-
-# Global PTY manager instance
-_pty_manager: Optional[PTYManager] = None
-
-
-def get_pty_manager() -> PTYManager:
- """Get or create the global PTY manager instance."""
- global _pty_manager
- if _pty_manager is None:
- _pty_manager = PTYManager()
- return _pty_manager
diff --git a/code_puppy/api/templates/terminal.html b/code_puppy/api/templates/terminal.html
deleted file mode 100644
index 568dc7491..000000000
--- a/code_puppy/api/templates/terminal.html
+++ /dev/null
@@ -1,382 +0,0 @@
-
-
-