diff --git a/AGENTS.md b/AGENTS.md index 89442334..d1bc91a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,8 +30,8 @@ This is a Rust-based tool for AI-assisted code tasks with multiple operational m Layer 0 (generic): llm command_executor fs_explorer sandbox web git terminal terminal_output Layer 1 (generic): tools_core — tool trait, registry, render, spec, permissions Layer 2 (generic): agent_core — agent loop, hook traits, dialect trait, AgentUi trait -Layer 3 (domain): code_assistant_core — sessions, persistence, UiEvent, tool impls, - dialects (xml/caret), plugins, sub-agents, backend +Layer 3 (domain): code_assistant_core — sessions, SessionService, event stream, UiEvent, + tool impls, dialects (xml/caret), plugins, sub-agents Layer 4 (frontends): ui_gpui ui_terminal ui_acp mcp_server Layer 5 (binary): code_assistant — CLI, config, feature-gated frontend wiring ``` @@ -94,41 +94,50 @@ headless binary without gpui. ## UI Communication Architecture -### Communication Channels -There are **two main communication patterns** between components and the UI: - -1. **Direct UserInterface trait calls** (primary pattern): - - Agent calls methods like `begin_llm_request()`, `display()`, `update_tool_status()`, etc. - - The `display()` method takes a `UIMessage` which can wrap a `UiEvent` - - Can send any UI event by wrapping it: `UIMessage::UiEvent(event)` - - Events go into the main UI event queue processed by the first task - -2. **Backend thread communication** (session management): - - Used for session management operations (create, delete, list sessions) - - Has separate `BackendEvent`/`BackendResponse` types and channels (`code_assistant_core::backend`) - - Handled by a second task running concurrently - - Operations: `LoadSession`, `CreateNewSession`, `ListSessions`, etc. - -### Event Queue Architecture -- **Two event queues** running concurrently with separate tasks -- **Task 1**: Processes `UiEvent`s from UserInterface trait calls -- **Task 2**: Handles session management `BackendEvent`s and `BackendResponse`s -- Architecture acknowledged as "messy" and should be cleaned up eventually +Two directions across one seam (`code_assistant_core::session`): + +1. **UI → core: `SessionService`** (`session/service.rs`) — every command a + frontend issues (create/load/delete session, send/queue message, switch + model/sandbox/worktree, branching, skills, `request_stop`) is a typed async + method returning `Result`. Internally an actor: methods enqueue a + closure on a command channel and await a oneshot reply; a single worker + (spawned on the backend tokio runtime by the wiring) executes commands in + order. `load_session` returns an owned `SessionSnapshot` (transcript incl. + in-flight partial response, tool results, plan, activity, model/sandbox + state); `SessionSnapshot::connect_events()` renders it as the canonical + event sequence. + +2. **Core → UI: broadcast `EventStream`** (`session/event_stream.rs`) — all + notifications (streaming `DisplayFragment`s, `UiEvent`s) are published + session-tagged; frontends `subscribe()` and filter by the session they + view (sidebar-relevant events like activity/metadata pass regardless). + A lagged subscriber gets `StreamError::Lagged` and resyncs via a fresh + snapshot. The core does not know which session is "connected" or how many + views exist. ### Concurrent Agent System -- **Multiple agents** can run concurrently, one per session -- **Only one agent** is connected to the UI at any time -- **ProxyUI system**: Each session gets a `ProxyUI` instance that only forwards events and method calls to the real UI when that session is "connected" -- **Session states**: - - **Connected**: Session is actively connected to UI (user clicked on chat item in sidebar) - - **Active**: Agent loop is currently running in the session (can be active without being connected) -- **Session switching**: User clicks chat items in sidebar to connect/activate different sessions - -### Key Implementation Details -- Agent-to-UI communication should use existing `self.ui.display(UIMessage::UiEvent(...))` pattern -- Avoid overcomplicating with new channels or architectures -- Leverage the ProxyUI system for proper session isolation -- Session metadata updates can be sent directly via the existing event system +- **Multiple agents** can run concurrently, one per session; any number of + frontends/views can observe them via the stream +- **`SessionEventPublisher`** (`session/instance.rs`) implements the + `UserInterface` trait for the agent seam: it publishes everything and + records per-session in-flight state (fragments of the streaming response, + live tool statuses) that snapshots include; activity-state transition rules + live in `SessionActivity` +- **Cancellation** is a core-side per-session flag (`request_stop`), checked + by the agent at streaming checkpoints — works for background sessions too + +### Frontend patterns +- **GPUI**: commands in `ui_gpui/src/app/commands.rs` (dispatched on the + background executor), stream ingestion in `app/event_bridge.rs` +- **Terminal**: commands via the `Actions` struct, bridge task in + `ui_terminal/src/app.rs` +- **ACP**: routes stream events to per-prompt `ACPUserUI` instances via its + `active_uis` registry (`ui_acp/src/app.rs`); its session/prompt commands + intentionally use `SessionManager` directly — the protocol-adapter needs + (client-specified session ids, per-prompt agent starts, completion waiting) + don't map onto `SessionService` +- The filesystem `SessionWatcher` still pushes `UiEvent`s directly into + frontend channels (not via the stream) — a known remaining seam (Below instructions copied from Zed's `.rules` file) diff --git a/crates/code_assistant/src/app/gpui.rs b/crates/code_assistant/src/app/gpui.rs index eb526a09..9ab6c176 100644 --- a/crates/code_assistant/src/app/gpui.rs +++ b/crates/code_assistant/src/app/gpui.rs @@ -1,22 +1,17 @@ use super::AgentRunConfig; -use crate::config::DefaultProjectManager; use crate::session::watcher::SessionWatcher; use crate::session::{SessionConfig, SessionManager}; -use crate::ui::UserInterface; use anyhow::Result; -use llm::factory::create_llm_client_from_model; +use code_assistant_core::session::event_stream::EventStream; +use code_assistant_core::session::service::{AgentRuntimeOptions, SessionService}; use std::sync::Arc; use tokio::sync::Mutex; use tracing::{debug, error, info, warn}; -use ui_gpui::terminal::executor::GpuiTerminalCommandExecutor; pub fn run(config: AgentRunConfig) -> Result<()> { // Create shared state between GUI and backend let gui = ui_gpui::Gpui::new(); - // Setup unified backend communication - let (backend_event_rx, backend_response_tx) = gui.setup_backend_communication(); - // Setup dynamic types for MultiSessionManager let persistence = crate::persistence::FileSessionPersistence::new(); @@ -31,153 +26,50 @@ pub fn run(config: AgentRunConfig) -> Result<()> { ..SessionConfig::default() }; - let default_model = config.model.clone(); - let base_session_model_config = - crate::persistence::SessionModelConfig::new(default_model.clone()); - // Clone persistence before it is moved into SessionManager so the // filesystem watcher can use it to resolve the sessions directory. let persistence_for_watcher = persistence.clone(); - // Create the new SessionManager + let events = EventStream::new(); let multi_session_manager = Arc::new(Mutex::new(SessionManager::new( persistence, session_config_template, - default_model.clone(), + config.model.clone(), code_assistant_core::tools::default_registry(), + events.clone(), ))); - // Clone GUI before moving it into thread + // Create the session command service. The GUI gets the handle; the + // worker runs on the backend tokio runtime below. The GUI consumes the + // broadcast stream (see ui_gpui's event bridge). + let (service, service_worker) = SessionService::new( + multi_session_manager, + Arc::new(AgentRuntimeOptions { + record_path: config.record.clone(), + playback_path: config.playback.clone(), + fast_playback: config.fast_playback, + command_executor_factory: super::session_command_executor_factory(), + }), + events, + ); + gui.set_session_service(service.clone()); + let gui_for_thread = gui.clone(); - let task_clone = config.task.clone(); - let model = default_model; - let base_model_config = base_session_model_config.clone(); - let record = config.record.clone(); - let playback = config.playback.clone(); - let fast_playback = config.fast_playback; - - // Start the simplified backend thread + let task = config.task.clone(); + + // Start the backend thread: runs the service worker and the startup + // session connection on its own tokio runtime. std::thread::spawn(move || { let runtime = tokio::runtime::Runtime::new().unwrap(); runtime.block_on(async { - if let Some(initial_task) = task_clone { - // Task provided - create new session and start agent - debug!("Creating initial session with task: {}", initial_task); - - let session_id = { - let mut manager = multi_session_manager.lock().await; - manager - .create_session_with_config(None, None, Some(base_model_config.clone())) - .unwrap() - }; - - debug!("Created initial session: {}", session_id); - - // Connect session to UI and start agent - let ui_events = { - let mut manager = multi_session_manager.lock().await; - manager - .set_active_session(session_id.clone(), None) - .await - .unwrap_or_else(|e| { - error!("Failed to set active session: {}", e); - Vec::new() - }) - }; - - for event in ui_events { - if let Err(e) = gui_for_thread.send_event(event).await { - error!("Failed to send UI event: {}", e); - } - } - - // Populate the skill catalog for the `/skill` input-area popup. - gui_for_thread.refresh_skills(session_id.clone()); - - let project_manager = Box::new(DefaultProjectManager::new()); - let command_executor = - Box::new(GpuiTerminalCommandExecutor::new(session_id.clone())); - let user_interface: Arc = - Arc::new(gui_for_thread.clone()); - - let llm_client = create_llm_client_from_model( - &model, - playback.clone(), - fast_playback, - record.clone(), - ) - .await - .expect("Failed to create LLM client"); - - { - let mut manager = multi_session_manager.lock().await; - manager - .start_agent_for_message( - &session_id, - vec![llm::ContentBlock::new_text(initial_task)], - None, // Initial task is not a branch - llm_client, - project_manager, - command_executor, - user_interface, - None, - ) - .await - .expect("Failed to start agent with initial task"); - } - - debug!("Started agent for initial session"); - } else { - // No task - connect to latest existing session - info!("No task provided, connecting to latest session"); - - let latest_session_id = { - let manager = multi_session_manager.lock().await; - manager.get_latest_session_id().unwrap_or(None) - }; - - if let Some(session_id) = latest_session_id { - debug!("Connecting to existing session: {}", session_id); - - // If the session's draft is in edit mode, connect with the - // transcript already truncated to the branch parent so the - // edit view is restored directly on startup. - let edit_until_node_id = gui_for_thread - .load_draft_for_session(&session_id) - .and_then(|(_, _, anchor)| anchor); - - let ui_events = { - let mut manager = multi_session_manager.lock().await; - manager - .set_active_session(session_id.clone(), edit_until_node_id) - .await - .unwrap_or_else(|e| { - error!("Failed to set active session: {}", e); - Vec::new() - }) - }; - - for event in ui_events { - if let Err(e) = gui_for_thread.send_event(event).await { - error!("Failed to send UI event: {}", e); - } - } + let worker = tokio::spawn(service_worker); - // Populate the skill catalog for the `/skill` input-area popup. - gui_for_thread.refresh_skills(session_id.clone()); - } else { - info!("No existing sessions found - showing empty state (no session view)"); - // In GPUI mode, don't auto-create a session. The user can - // create one from the sidebar. The MessagesView will render - // the "no session" hint since no session is connected. - } - } + startup(&service, &gui_for_thread, task).await; // Start the filesystem watcher for cross-instance awareness. // The watcher runs in the background and emits UI events when // other code-assistant instances modify session files. - let _session_watcher = match SessionWatcher::start( &persistence_for_watcher, gui_for_thread.event_sender(), @@ -193,19 +85,8 @@ pub fn run(config: AgentRunConfig) -> Result<()> { } }; - code_assistant_core::backend::handle_backend_events( - backend_event_rx, - backend_response_tx, - multi_session_manager, - Arc::new(code_assistant_core::backend::BackendRuntimeOptions { - record_path: record.clone(), - playback_path: playback.clone(), - fast_playback, - command_executor_factory: super::session_command_executor_factory(), - }), - Arc::new(gui_for_thread) as Arc, - ) - .await; + // Keep the runtime alive for the lifetime of the app. + let _ = worker.await; }); }); @@ -214,3 +95,79 @@ pub fn run(config: AgentRunConfig) -> Result<()> { Ok(()) } + +/// Connect the initial session: either create one for a provided task and +/// start the agent, or connect to the latest existing session. +async fn startup(service: &SessionService, gui: &ui_gpui::Gpui, task: Option) { + let session_id = if let Some(initial_task) = task { + // Task provided - create a new session and start the agent for it + debug!("Creating initial session with task"); + let session_id = match service.create_session(None, None).await { + Ok(id) => id, + Err(e) => { + error!("Failed to create initial session: {e:#}"); + return; + } + }; + match service.load_session(session_id.clone(), None).await { + Ok(snapshot) => gui.apply_snapshot(&snapshot), + Err(e) => { + error!("Failed to connect initial session: {e:#}"); + return; + } + } + if let Err(e) = service + .send_user_message(session_id.clone(), initial_task, Vec::new(), None) + .await + { + error!("Failed to start agent with initial task: {e:#}"); + } + Some(session_id) + } else { + // No task - connect to the latest existing session, if any + info!("No task provided, connecting to latest session"); + let latest = service + .list_sessions() + .await + .ok() + .and_then(|sessions| sessions.first().map(|s| s.id.clone())); + + match latest { + Some(session_id) => { + debug!("Connecting to existing session: {}", session_id); + // If the session's draft is in edit mode, connect with the + // transcript already truncated to the branch parent so the + // edit view is restored directly on startup. + let edit_until_node_id = gui + .load_draft_for_session(&session_id) + .and_then(|(_, _, anchor)| anchor); + match service + .load_session(session_id.clone(), edit_until_node_id) + .await + { + Ok(snapshot) => gui.apply_snapshot(&snapshot), + Err(e) => { + error!("Failed to connect to session {session_id}: {e:#}"); + return; + } + } + Some(session_id) + } + None => { + info!("No existing sessions found - showing empty state (no session view)"); + // In GPUI mode, don't auto-create a session. The user can + // create one from the sidebar. The MessagesView will render + // the "no session" hint since no session is connected. + None + } + } + }; + + // Populate the skill catalog for the `/skill` input-area popup. + if let Some(session_id) = session_id { + match service.list_skills(session_id).await { + Ok(skills) => gui.set_skills(skills), + Err(e) => debug!("Failed to list skills at startup: {e:#}"), + } + } +} diff --git a/crates/code_assistant/src/app/mod.rs b/crates/code_assistant/src/app/mod.rs index d2e5fc2d..a777670f 100644 --- a/crates/code_assistant/src/app/mod.rs +++ b/crates/code_assistant/src/app/mod.rs @@ -10,7 +10,7 @@ pub mod terminal; pub use code_assistant_core::config::AgentRunConfig; #[cfg(any(feature = "gpui-frontend", feature = "terminal-frontend"))] -use code_assistant_core::backend::CommandExecutorFactory; +use code_assistant_core::session::service::CommandExecutorFactory; /// The command executor the interactive frontends use for agent sessions: /// commands run attached to live terminal views when the GPUI terminal pool diff --git a/crates/code_assistant_core/resources/skills/samples/skill-creator/SKILL.md b/crates/code_assistant_core/resources/skills/samples/skill-creator/SKILL.md index 28d6adbd..c7ff533a 100644 --- a/crates/code_assistant_core/resources/skills/samples/skill-creator/SKILL.md +++ b/crates/code_assistant_core/resources/skills/samples/skill-creator/SKILL.md @@ -19,8 +19,9 @@ A skill lives in one of three scopes. Pick based on how widely it should apply: - **project** — `/.agents/skills//` — specific to one repo. Load with `read_skill(project="", name="")`; read its resources with `read_files(project="", ...)`. -- **user** — addressed by the scope token `:config:` — your personal skills, - available in every project. Load with `read_skill(project=":config:", ...)`. +- **user** — `~/.agents/skills//` — your personal skills, available in + every project (and shared with other agent harnesses). Addressed by the scope + token `:config:`; load with `read_skill(project=":config:", ...)`. - **system** — token `:system:` — bundled skills (like this one). Usually read-only. diff --git a/crates/code_assistant_core/src/agent/sub_agent.rs b/crates/code_assistant_core/src/agent/sub_agent.rs index 677df283..92514246 100644 --- a/crates/code_assistant_core/src/agent/sub_agent.rs +++ b/crates/code_assistant_core/src/agent/sub_agent.rs @@ -811,8 +811,4 @@ impl UserInterface for SubAgentUiAdapter { fn notify_rate_limit(&self, _seconds_remaining: u64) {} fn clear_rate_limit(&self) {} - - fn as_any(&self) -> &dyn std::any::Any { - self - } } diff --git a/crates/code_assistant_core/src/backend.rs b/crates/code_assistant_core/src/backend.rs deleted file mode 100644 index 60373b38..00000000 --- a/crates/code_assistant_core/src/backend.rs +++ /dev/null @@ -1,1852 +0,0 @@ -use crate::config::{save_project, DefaultProjectManager}; -use crate::persistence::{ChatMetadata, DraftAttachment, SessionModelConfig}; -use crate::session::SessionManager; -use crate::skills::{ - discover_session_catalog, load_skill_payload, render_skill_invocation_message, SkillsConfig, -}; -use crate::types::Project; -use crate::ui::UserInterface; -use crate::utils::content::content_blocks_from; -use command_executor::CommandExecutor; -use llm::factory::create_llm_client_from_model; -use llm::provider_config::ConfigurationSystem; -use sandbox::SandboxPolicy; - -use std::path::PathBuf; -use std::sync::Arc; -use tokio::sync::Mutex; -use tracing::{debug, error, info, trace, warn}; - -// Unified event type for all UI→Backend communication -#[derive(Debug, Clone)] -pub enum BackendEvent { - // Session management - LoadSession { - session_id: String, - /// When `Some(_)`, the session's draft is in "edit mode" and the - /// transcript should be truncated to messages up to and including this - /// node (the branch parent of the message being edited), so the edit - /// view is restored in a single event. - edit_until_node_id: Option, - }, - - CreateNewSession { - name: Option, - initial_project: Option, - }, - DeleteSession { - session_id: String, - }, - ListSessions, - - // Agent operations - SendUserMessage { - session_id: String, - message: String, - attachments: Vec, - /// If set, creates a new branch from this parent node instead of appending to active path - branch_parent_id: Option, - }, - QueueUserMessage { - session_id: String, - message: String, - attachments: Vec, - }, - RequestPendingMessageEdit { - session_id: String, - }, - - /// List the skills available to a session (across project / user / system - /// scopes), for the input-area skill picker. Includes skills flagged - /// `disable-model-invocation`, since a user may invoke any of them. - ListSkills { - session_id: String, - }, - - /// User-initiated ("explicit") skill activation: load the skill's body and - /// inject it directly as a synthetic user message, then run the agent. No - /// `read_skill` round-trip is needed. - InvokeSkill { - session_id: String, - /// Scope token: the session's project name, or `:config:` / `:system:`. - scope: String, - name: String, - }, - - // Model management - SwitchModel { - session_id: String, - model_name: String, - }, - ChangeSandboxPolicy { - session_id: String, - policy: SandboxPolicy, - }, - - // Sub-agent management - CancelSubAgent { - session_id: String, - tool_id: String, - }, - - // Session branching - StartMessageEdit { - session_id: String, - node_id: crate::persistence::NodeId, - }, - SwitchBranch { - session_id: String, - new_node_id: crate::persistence::NodeId, - }, - CancelMessageEdit { - session_id: String, - }, - - // Git worktree management - ListBranchesAndWorktrees { - session_id: String, - }, - SwitchWorktree { - session_id: String, - worktree_path: Option, - branch: Option, - }, - - #[allow(dead_code)] - CreateWorktree { - session_id: String, - branch_name: String, - base_branch: Option, - }, - - /// Add a new project to projects.json and create an initial session - AddProject { - name: String, - path: PathBuf, - }, - - /// Persist a temporary project to projects.json so it becomes a first-class project - PersistProject { - project_name: String, - }, - - /// Clear the Errored state on a session (user dismissed the error banner) - ClearSessionError { - session_id: String, - }, - - /// Resume a session that ended in a state where the agent should run - /// against the existing message history (no new user message is added). - /// Used by the GPUI "Resume" button to recover sessions that crashed or - /// were killed mid-iteration. - ResumeSession { - session_id: String, - }, - - /// Clear the conversation context (messages) for a session. - /// - /// The session itself is kept alive; only the message history is wiped. - /// The UI transcript is cleared via `UiEvent::ClearMessages`. - ClearContext { - session_id: String, - }, - - /// Compact (summarise) conversation context for a session. - /// - /// Not yet implemented — emits an informational message instead. - CompactContext { - session_id: String, - }, - - /// Incremental session refresh triggered by the file watcher. - /// Compares the on-disk state with the in-memory state and emits only - /// the delta (new messages appended by an external process). - RefreshSession { - session_id: String, - }, - - /// Update the default model name used for newly created sessions. - /// Sent after onboarding writes config and sets a default model. - UpdateDefaultModel { - model_name: String, - }, -} - -/// A single entry in the input-area skill picker. -#[derive(Debug, Clone)] -pub struct SkillCatalogEntry { - pub name: String, - pub description: String, - /// Scope token to pass back in [`BackendEvent::InvokeSkill`] (the project - /// name, or `:config:` / `:system:`). - pub scope_token: String, - /// Human-readable scope label (`project` / `user` / `system`). - pub scope_label: String, -} - -// Response from backend to UI -#[derive(Debug, Clone)] -pub enum BackendResponse { - SessionCreated { - session_id: String, - }, - - /// The skills available to a session, for the input-area picker. - SkillsListed { - session_id: String, - skills: Vec, - }, - #[allow(dead_code)] - SessionDeleted { - session_id: String, - }, - SessionsListed { - sessions: Vec, - }, - Error { - message: String, - }, - PendingMessageForEdit { - session_id: String, - #[allow(dead_code)] - message: String, - }, - PendingMessageUpdated { - session_id: String, - message: Option, - }, - ModelSwitched { - session_id: String, - model_name: String, - /// Optional warning to surface to the user, e.g. when the switch will - /// only affect the next agent iteration. - warning: Option, - /// Models that remain valid choices for this session after the switch. - allowed_models: Vec, - }, - - SandboxPolicyChanged { - session_id: String, - policy: SandboxPolicy, - }, - - SubAgentCancelled { - session_id: String, - tool_id: String, - }, - - // Session branching responses - MessageEditReady { - session_id: String, - content: String, - attachments: Vec, - branch_parent_id: Option, - /// Messages up to (but not including) the message being edited - messages: Vec, - tool_results: Vec, - }, - - BranchSwitched { - session_id: String, - messages: Vec, - tool_results: Vec, - plan: crate::types::PlanState, - }, - - MessageEditCancelled { - session_id: String, - messages: Vec, - tool_results: Vec, - }, - - // Git worktree responses - BranchesAndWorktreesListed { - session_id: String, - #[allow(dead_code)] - branches: Vec, - worktrees: Vec, - #[allow(dead_code)] - current_branch: Option, - is_git_repo: bool, - }, - WorktreeSwitched { - session_id: String, - worktree_path: Option, - branch: Option, - }, - - WorktreeCreated { - session_id: String, - worktree_path: PathBuf, - branch: String, - }, - - /// A new project was added and an initial session was created for it - ProjectAdded { - project_name: String, - session_id: String, - }, - - /// A temporary project was persisted to projects.json - ProjectPersisted { - project_name: String, - }, - - /// The project already exists (same name and path) — no changes were made - ProjectAlreadyExists { - project_name: String, - }, -} - -/// Creates the per-session command executor for agent runs. Injected by the -/// wiring so the backend stays frontend-agnostic (the GPUI build supplies an -/// executor that can attach commands to live terminal views). -pub type CommandExecutorFactory = Arc Box + Send + Sync>; - -#[derive(Clone)] -pub struct BackendRuntimeOptions { - pub record_path: Option, - pub playback_path: Option, - pub fast_playback: bool, - /// Builds the command executor for a session id when an agent is started. - pub command_executor_factory: CommandExecutorFactory, -} - -pub async fn handle_backend_events( - backend_event_rx: async_channel::Receiver, - backend_response_tx: async_channel::Sender, - multi_session_manager: Arc>, - runtime_options: Arc, - ui: Arc, -) { - debug!("Backend event handler started"); - - while let Ok(event) = backend_event_rx.recv().await { - debug!("Backend event: {:?}", event); - - let response = match event { - BackendEvent::ListSessions => Some(handle_list_sessions(&multi_session_manager).await), - - BackendEvent::CreateNewSession { - name, - initial_project, - } => Some(handle_create_session(&multi_session_manager, name, initial_project).await), - - BackendEvent::LoadSession { - session_id, - edit_until_node_id, - } => { - handle_load_session(&multi_session_manager, &session_id, edit_until_node_id, &ui) - .await - } - - BackendEvent::DeleteSession { session_id } => { - Some(handle_delete_session(&multi_session_manager, &session_id).await) - } - - BackendEvent::SendUserMessage { - session_id, - message, - attachments, - branch_parent_id, - } => { - handle_send_user_message( - &multi_session_manager, - &session_id, - &message, - &attachments, - branch_parent_id, - runtime_options.as_ref(), - &ui, - ) - .await - } - - BackendEvent::QueueUserMessage { - session_id, - message, - attachments, - } => Some( - handle_queue_user_message( - &multi_session_manager, - &session_id, - &message, - &attachments, - ) - .await, - ), - - BackendEvent::RequestPendingMessageEdit { session_id } => { - Some(handle_request_pending_message_edit(&multi_session_manager, &session_id).await) - } - - BackendEvent::ListSkills { session_id } => { - Some(handle_list_skills(&multi_session_manager, &session_id).await) - } - - BackendEvent::InvokeSkill { - session_id, - scope, - name, - } => { - handle_invoke_skill( - &multi_session_manager, - &session_id, - &scope, - &name, - runtime_options.as_ref(), - &ui, - ) - .await - } - - BackendEvent::SwitchModel { - session_id, - model_name, - } => Some(handle_switch_model(&multi_session_manager, &session_id, &model_name).await), - BackendEvent::ChangeSandboxPolicy { session_id, policy } => Some( - handle_change_sandbox_policy(&multi_session_manager, &session_id, policy).await, - ), - - BackendEvent::CancelSubAgent { - session_id, - tool_id, - } => Some(handle_cancel_sub_agent(&multi_session_manager, &session_id, &tool_id).await), - - BackendEvent::StartMessageEdit { - session_id, - node_id, - } => { - Some(handle_start_message_edit(&multi_session_manager, &session_id, node_id).await) - } - - BackendEvent::SwitchBranch { - session_id, - new_node_id, - } => Some(handle_switch_branch(&multi_session_manager, &session_id, new_node_id).await), - - BackendEvent::CancelMessageEdit { session_id } => { - Some(handle_cancel_message_edit(&multi_session_manager, &session_id).await) - } - - BackendEvent::ListBranchesAndWorktrees { session_id } => { - Some(handle_list_branches_and_worktrees(&multi_session_manager, &session_id).await) - } - - BackendEvent::SwitchWorktree { - session_id, - worktree_path, - branch, - } => Some( - handle_switch_worktree(&multi_session_manager, &session_id, worktree_path, branch) - .await, - ), - - BackendEvent::CreateWorktree { - session_id, - branch_name, - base_branch, - } => Some( - handle_create_worktree( - &multi_session_manager, - &session_id, - &branch_name, - base_branch.as_deref(), - ) - .await, - ), - - BackendEvent::AddProject { name, path } => { - Some(handle_add_project(&multi_session_manager, &name, &path).await) - } - - BackendEvent::PersistProject { project_name } => { - Some(handle_persist_project(&multi_session_manager, &project_name).await) - } - - BackendEvent::ClearSessionError { session_id } => { - let mut manager = multi_session_manager.lock().await; - if let Some(session) = manager.get_session_mut(&session_id) { - let current = session.get_activity_state(); - if current.is_terminal() { - session.set_activity_state( - crate::session::instance::SessionActivityState::Idle, - ); - } - } - // Broadcast the state change so the sidebar updates - let _ = ui - .send_event(crate::ui::UiEvent::UpdateSessionActivityState { - session_id, - activity_state: crate::session::instance::SessionActivityState::Idle, - }) - .await; - None // No backend response needed - } - - BackendEvent::ResumeSession { session_id } => { - handle_resume_session( - &multi_session_manager, - &session_id, - runtime_options.as_ref(), - &ui, - ) - .await - } - - BackendEvent::RefreshSession { session_id } => { - handle_refresh_session(&multi_session_manager, &session_id, &ui).await - } - - BackendEvent::ClearContext { session_id } => { - let mut manager = multi_session_manager.lock().await; - if let Some(session) = manager.get_session_mut(&session_id) { - let chat = &mut session.session; - chat.message_nodes.clear(); - chat.active_path.clear(); - chat.next_node_id = 1; - chat.messages.clear(); - chat.plan = Default::default(); - } - let _ = ui.send_event(crate::ui::UiEvent::ClearMessages).await; - None - } - - BackendEvent::CompactContext { session_id: _ } => { - let _ = ui - .send_event(crate::ui::UiEvent::DisplayError { - message: "Compact is not yet implemented. Use /clear to reset context." - .to_string(), - }) - .await; - None - } - - BackendEvent::UpdateDefaultModel { model_name } => { - debug!("Updating default model name to: {}", model_name); - let mut manager = multi_session_manager.lock().await; - manager.set_default_model_name(model_name); - None - } - }; - - // Send response back to UI only if there is one - if let Some(response) = response { - if let Err(e) = backend_response_tx.send(response).await { - error!("Failed to send response: {}", e); - break; - } - } - } - - debug!("Backend event handler stopped"); -} - -async fn handle_list_sessions( - multi_session_manager: &Arc>, -) -> BackendResponse { - let sessions = { - let manager = multi_session_manager.lock().await; - manager.list_all_sessions() - }; - match sessions { - Ok(sessions) => { - trace!("Found {} sessions", sessions.len()); - BackendResponse::SessionsListed { sessions } - } - Err(e) => { - error!("Failed to list sessions: {}", e); - BackendResponse::Error { - message: e.to_string(), - } - } - } -} - -async fn handle_create_session( - multi_session_manager: &Arc>, - name: Option, - initial_project: Option, -) -> BackendResponse { - let create_result = { - let mut manager = multi_session_manager.lock().await; - if let Some(project) = initial_project { - // Create a session config override with the specified project. - // Resolve the project path so the new session gets the correct - // CWD — either from projects.json or from a sibling session that - // already ran in that (temporary) project. - let mut config = manager.session_config_template().clone(); - config.initial_project = project.clone(); - if let Some(path) = manager.resolve_project_path(&project) { - config.init_path = Some(path); - } - manager.create_session_with_config(name.clone(), Some(config), None) - } else { - manager.create_session(name.clone()) - } - }; - - match create_result { - Ok(session_id) => { - info!("Created session {}", session_id); - BackendResponse::SessionCreated { session_id } - } - Err(e) => { - error!("Failed to create session: {}", e); - BackendResponse::Error { - message: e.to_string(), - } - } - } -} - -async fn handle_load_session( - multi_session_manager: &Arc>, - session_id: &str, - edit_until_node_id: Option, - ui: &Arc, -) -> Option { - debug!("LoadSession requested: {}", session_id); - - let ui_events_result = { - let mut manager = multi_session_manager.lock().await; - manager - .set_active_session(session_id.to_string(), edit_until_node_id) - .await - }; - - match ui_events_result { - Ok(ui_events) => { - trace!("Session connected with {} UI events", ui_events.len()); - - // Send all UI events to update the interface - for event in ui_events { - if let Err(e) = ui.send_event(event).await { - error!("Failed to send UI event: {}", e); - } - } - - let allowed_models = { - let manager = multi_session_manager.lock().await; - manager - .allowed_models_for_session(session_id) - .unwrap_or_default() - }; - if let Err(e) = ui - .send_event(crate::ui::UiEvent::UpdateAllowedModels { - models: allowed_models, - }) - .await - { - error!("Failed to send allowed model update: {}", e); - } - - // No response needed - UI events already handled the update. - None - } - Err(e) => { - error!("Failed to connect to session {}: {}", session_id, e); - Some(BackendResponse::Error { - message: e.to_string(), - }) - } - } -} - -async fn handle_refresh_session( - multi_session_manager: &Arc>, - session_id: &str, - ui: &Arc, -) -> Option { - let ui_events_result = { - let mut manager = multi_session_manager.lock().await; - manager.refresh_session_incremental(session_id) - }; - - match ui_events_result { - Ok(ui_events) => { - if !ui_events.is_empty() { - trace!( - "Incremental refresh for {session_id}: {} UI events", - ui_events.len() - ); - for event in ui_events { - if let Err(e) = ui.send_event(event).await { - error!("Failed to send UI event: {}", e); - } - } - } - None - } - Err(e) => { - warn!("Incremental refresh failed for {session_id}, falling back: {e}"); - // Fall back to full reload - handle_load_session(multi_session_manager, session_id, None, ui).await - } - } -} - -async fn handle_delete_session( - multi_session_manager: &Arc>, - session_id: &str, -) -> BackendResponse { - debug!("DeleteSession requested: {}", session_id); - - let delete_result = { - let mut manager = multi_session_manager.lock().await; - manager.delete_session(session_id) - }; - - match delete_result { - Ok(_) => { - debug!("Session deleted: {}", session_id); - BackendResponse::SessionDeleted { - session_id: session_id.to_string(), - } - } - Err(e) => { - error!("Failed to delete session {}: {}", session_id, e); - BackendResponse::Error { - message: e.to_string(), - } - } - } -} - -async fn handle_send_user_message( - multi_session_manager: &Arc>, - session_id: &str, - message: &str, - attachments: &[DraftAttachment], - branch_parent_id: Option, - runtime_options: &BackendRuntimeOptions, - ui: &Arc, -) -> Option { - debug!( - "User message for session {}: {} (with {} attachments, branch_parent: {:?})", - session_id, - message, - attachments.len(), - branch_parent_id - ); - - // Convert DraftAttachments to ContentBlocks - let content_blocks = content_blocks_from(message, attachments); - - // First, add the user message to the session and get the new node_id - let (new_node_id, branch_info_updates) = { - let mut manager = multi_session_manager.lock().await; - match manager.add_user_message(session_id, content_blocks.clone(), branch_parent_id) { - Ok(node_id) => { - // If we created a branch, get branch info updates for all siblings - let updates = if branch_parent_id.is_some() { - manager.get_sibling_branch_infos(session_id, node_id) - } else { - Vec::new() - }; - (Some(node_id), updates) - } - Err(e) => { - error!("Failed to add user message to session: {}", e); - return Some(BackendResponse::Error { - message: format!("Failed to add user message: {e}"), - }); - } - } - }; - - // Now display the user message with the correct node_id - if let Err(e) = ui - .send_event(crate::ui::UiEvent::DisplayUserInput { - content: message.to_string(), - attachments: attachments.to_vec(), - node_id: new_node_id, - }) - .await - { - error!("Failed to display user message with attachments: {}", e); - } - - // Send branch info updates for all siblings (so they show the branch switcher) - for (sibling_node_id, branch_info) in branch_info_updates { - if let Err(e) = ui - .send_event(crate::ui::UiEvent::UpdateBranchInfo { - node_id: sibling_node_id, - branch_info, - }) - .await - { - error!("Failed to send branch info update: {}", e); - } - } - - // Start the agent (message already added) - let result = { - let project_manager = Box::new(DefaultProjectManager::new()); - let command_executor = (runtime_options.command_executor_factory)(session_id); - let user_interface = ui.clone(); - - // Check if session has stored model config, otherwise use global config - let session_config = { - let manager = multi_session_manager.lock().await; - manager.get_session_model_config(session_id).unwrap_or(None) - }; - - // Use model-based configuration system - let llm_client = if let Some(ref session_config) = session_config { - // Use session's stored model - create_llm_client_from_model( - &session_config.model_name, - runtime_options.playback_path.clone(), - runtime_options.fast_playback, - runtime_options.record_path.clone(), - ) - .await - } else { - // No session config - this should not happen in the new system - return Some(BackendResponse::Error { - message: "Session has no model configuration. Please ensure all sessions are created with a model.".to_string(), - }); - }; - - match llm_client { - Ok(client) => { - let mut manager = multi_session_manager.lock().await; - if let Err(e) = manager.set_session_model_config(session_id, session_config.clone()) - { - error!( - "Failed to persist model config for session {}: {}", - session_id, e - ); - Err(e) - } else { - // Message already added via add_user_message above - manager - .start_agent_for_session( - session_id, - client, - project_manager, - command_executor, - user_interface, - None, - ) - .await - } - } - Err(e) => { - error!("Failed to create LLM client: {}", e); - Err(e) - } - } - }; - - match result { - Ok(_) => { - debug!("Agent started for session {}", session_id); - // No response needed - agent is running - None - } - Err(e) => { - error!("Failed to start agent for session {}: {}", session_id, e); - Some(BackendResponse::Error { - message: format!("Failed to start agent: {e}"), - }) - } - } -} - -/// Resume a session by starting an agent against its existing message history. -/// -/// Unlike [`handle_send_user_message`], this does **not** add a new user -/// message — it re-runs the agent loop on whatever the session ended with. -/// The agent's `normalize_loaded_message_history` will drop any dangling -/// assistant tool requests, so resuming a session whose last assistant -/// message has un-answered tool calls effectively retries the prior user/ -/// tool-result turn. -async fn handle_resume_session( - multi_session_manager: &Arc>, - session_id: &str, - runtime_options: &BackendRuntimeOptions, - ui: &Arc, -) -> Option { - debug!("ResumeSession requested for {}", session_id); - - // Refuse to resume if an agent is already running for this session, or - // if it's locked by another instance. - { - let manager = multi_session_manager.lock().await; - if manager.is_agent_locked_externally(session_id) { - return Some(BackendResponse::Error { - message: "Cannot resume: another instance is running this session.".to_string(), - }); - } - if let Some(instance) = manager.get_session(session_id) { - if !instance.get_activity_state().is_terminal() { - return Some(BackendResponse::Error { - message: "Cannot resume: agent is already running for this session." - .to_string(), - }); - } - } - } - - // Clear any prior Errored state so the UI doesn't keep the error banner. - { - let mut manager = multi_session_manager.lock().await; - if let Some(session) = manager.get_session_mut(session_id) { - if matches!( - session.get_activity_state(), - crate::session::instance::SessionActivityState::Errored { .. } - ) { - session.set_activity_state(crate::session::instance::SessionActivityState::Idle); - } - } - } - - let project_manager = Box::new(DefaultProjectManager::new()); - let command_executor = (runtime_options.command_executor_factory)(session_id); - let user_interface = ui.clone(); - - let session_config = { - let manager = multi_session_manager.lock().await; - manager.get_session_model_config(session_id).unwrap_or(None) - }; - - let llm_client = if let Some(ref session_config) = session_config { - create_llm_client_from_model( - &session_config.model_name, - runtime_options.playback_path.clone(), - runtime_options.fast_playback, - runtime_options.record_path.clone(), - ) - .await - } else { - return Some(BackendResponse::Error { - message: "Session has no model configuration; cannot resume.".to_string(), - }); - }; - - let result = match llm_client { - Ok(client) => { - let mut manager = multi_session_manager.lock().await; - manager - .start_agent_for_session( - session_id, - client, - project_manager, - command_executor, - user_interface, - None, - ) - .await - } - Err(e) => { - error!("Failed to create LLM client for resume: {}", e); - Err(e) - } - }; - - match result { - Ok(_) => { - debug!("Resumed agent for session {}", session_id); - None - } - Err(e) => { - error!("Failed to resume agent for session {}: {}", session_id, e); - Some(BackendResponse::Error { - message: format!("Failed to resume agent: {e}"), - }) - } - } -} - -async fn handle_queue_user_message( - multi_session_manager: &Arc>, - session_id: &str, - message: &str, - attachments: &[DraftAttachment], -) -> BackendResponse { - debug!( - "Queue user message with attachments for session {}: {} (with {} attachments)", - session_id, - message, - attachments.len() - ); - - // Convert DraftAttachments to ContentBlocks - let content_blocks = content_blocks_from(message, attachments); - - let result = { - let mut manager = multi_session_manager.lock().await; - manager.queue_structured_user_message(session_id, content_blocks) - }; - - match result { - Ok(_) => { - debug!("Message with attachments queued for session {}", session_id); - let pending_message = { - let manager = multi_session_manager.lock().await; - manager.get_pending_message(session_id).unwrap_or(None) - }; - BackendResponse::PendingMessageUpdated { - session_id: session_id.to_string(), - message: pending_message, - } - } - Err(e) => { - error!( - "Failed to queue message with attachments for session {}: {}", - session_id, e - ); - BackendResponse::Error { - message: format!("Failed to queue message: {e}"), - } - } - } -} - -async fn handle_request_pending_message_edit( - multi_session_manager: &Arc>, - session_id: &str, -) -> BackendResponse { - debug!("Request pending message edit for session {}", session_id); - - let result = { - let mut manager = multi_session_manager.lock().await; - manager.request_pending_message_for_edit(session_id) - }; - - match result { - Ok(Some(message)) => { - debug!("Retrieved pending message for editing: {}", message); - BackendResponse::PendingMessageForEdit { - session_id: session_id.to_string(), - message, - } - } - Ok(None) => { - debug!("No pending message found for session {}", session_id); - BackendResponse::PendingMessageUpdated { - session_id: session_id.to_string(), - message: None, - } - } - Err(e) => { - error!( - "Failed to get pending message for session {}: {}", - session_id, e - ); - BackendResponse::Error { - message: format!("Failed to get pending message: {e}"), - } - } - } -} - -/// List the skills available to a session, deduped across scopes with the same -/// precedence as the system-prompt catalog (project > user > system). Includes -/// `disable-model-invocation` skills since a user may invoke any of them. -async fn handle_list_skills( - multi_session_manager: &Arc>, - session_id: &str, -) -> BackendResponse { - let project_name = { - let manager = multi_session_manager.lock().await; - match manager.get_session(session_id) { - Some(session) => session.session.config.initial_project.clone(), - None => { - return BackendResponse::Error { - message: format!("Session {session_id} not found"), - } - } - } - }; - - let config = SkillsConfig::load(); - let pm = DefaultProjectManager::new(); - - let skills: Vec = discover_session_catalog(&pm, &project_name, &config) - .into_iter() - .map(|(skill, scope_token)| SkillCatalogEntry { - name: skill.name, - description: skill.description, - scope_label: skill.scope.label().to_string(), - scope_token, - }) - .collect(); - - BackendResponse::SkillsListed { - session_id: session_id.to_string(), - skills, - } -} - -/// Handle a user-initiated skill activation: load the body and inject it as a -/// synthetic user message, then run the agent (reusing the normal user-message -/// path). The activation is recorded in the session's `active_skills` so the -/// compaction reminder can re-surface it if the body is later dropped. -async fn handle_invoke_skill( - multi_session_manager: &Arc>, - session_id: &str, - scope: &str, - name: &str, - runtime_options: &BackendRuntimeOptions, - ui: &Arc, -) -> Option { - debug!("InvokeSkill `{name}` (scope `{scope}`) for session {session_id}"); - - let config = SkillsConfig::load(); - let pm = DefaultProjectManager::new(); - let payload = match load_skill_payload(&pm, scope, name, &config) { - Ok(payload) => payload, - Err(e) => { - error!("Failed to load skill `{name}` for {session_id}: {e}"); - return Some(BackendResponse::Error { - message: format!("Failed to load skill `{name}`: {e}"), - }); - } - }; - - let message = render_skill_invocation_message(&payload); - - // Record the activation (deduped) so compaction can remind the model if the - // injected body is summarised away. - { - let mut manager = multi_session_manager.lock().await; - if let Some(session) = manager.get_session_mut(session_id) { - if !session.session.active_skills.iter().any(|s| s == name) { - session.session.active_skills.push(name.to_string()); - } - } - if let Err(e) = manager.save_session(session_id) { - warn!("Failed to persist active_skills for {session_id}: {e}"); - } - } - - handle_send_user_message( - multi_session_manager, - session_id, - &message, - &[], - None, - runtime_options, - ui, - ) - .await -} - -async fn handle_switch_model( - multi_session_manager: &Arc>, - session_id: &str, - model_name: &str, -) -> BackendResponse { - debug!( - "Switching model for session {} to {}", - session_id, model_name - ); - - // Validate the requested model exists - let config_system = match ConfigurationSystem::load() { - Ok(system) => system, - Err(e) => { - error!("Failed to load model configuration: {}", e); - return BackendResponse::Error { - message: format!("Failed to load model configuration: {e}"), - }; - } - }; - - if config_system.get_model(model_name).is_none() { - error!("Model '{}' not found in configuration", model_name); - return BackendResponse::Error { - message: format!("Model '{model_name}' not found in configuration."), - }; - } - - let new_model_config = SessionModelConfig::new(model_name.to_string()); - - let result = { - let mut manager = multi_session_manager.lock().await; - manager.set_session_model_config(session_id, Some(new_model_config)) - }; - - match result { - Ok(outcome) => { - let warning = outcome.warning; - if let Some(warning) = &warning { - warn!("{}", warning); - } - let allowed_models = { - let manager = multi_session_manager.lock().await; - manager - .allowed_models_for_session(session_id) - .unwrap_or_default() - }; - - info!( - "Successfully switched model for session {} to {}", - session_id, model_name - ); - BackendResponse::ModelSwitched { - session_id: session_id.to_string(), - model_name: model_name.to_string(), - warning, - allowed_models, - } - } - Err(e) => { - error!("Failed to switch model for session {}: {}", session_id, e); - BackendResponse::Error { - message: format!("Failed to switch model: {e}"), - } - } - } -} - -async fn handle_change_sandbox_policy( - multi_session_manager: &Arc>, - session_id: &str, - policy: SandboxPolicy, -) -> BackendResponse { - let result = { - let mut manager = multi_session_manager.lock().await; - manager.set_session_sandbox_policy(session_id, policy.clone()) - }; - - match result { - Ok(()) => BackendResponse::SandboxPolicyChanged { - session_id: session_id.to_string(), - policy, - }, - Err(e) => BackendResponse::Error { - message: format!("Failed to update sandbox policy: {e}"), - }, - } -} - -async fn handle_cancel_sub_agent( - multi_session_manager: &Arc>, - session_id: &str, - tool_id: &str, -) -> BackendResponse { - debug!( - "Cancelling sub-agent {} for session {}", - tool_id, session_id - ); - - let result = { - let manager = multi_session_manager.lock().await; - manager.cancel_sub_agent(session_id, tool_id) - }; - - match result { - Ok(true) => { - info!( - "Successfully cancelled sub-agent {} for session {}", - tool_id, session_id - ); - BackendResponse::SubAgentCancelled { - session_id: session_id.to_string(), - tool_id: tool_id.to_string(), - } - } - Ok(false) => { - debug!( - "Sub-agent {} not found or already completed for session {}", - tool_id, session_id - ); - // Not really an error - the sub-agent may have already completed - BackendResponse::SubAgentCancelled { - session_id: session_id.to_string(), - tool_id: tool_id.to_string(), - } - } - - Err(e) => { - error!( - "Failed to cancel sub-agent {} for session {}: {}", - tool_id, session_id, e - ); - BackendResponse::Error { - message: format!("Failed to cancel sub-agent: {e}"), - } - } - } -} - -// ============================================================================ -// Session Branching Handlers -// ============================================================================ - -async fn handle_start_message_edit( - multi_session_manager: &Arc>, - session_id: &str, - node_id: crate::persistence::NodeId, -) -> BackendResponse { - debug!( - "Starting message edit for session {} node {}", - session_id, node_id - ); - - let result = { - let manager = multi_session_manager.lock().await; - if let Some(session_instance) = manager.get_session(session_id) { - // Get the message node - if let Some(node) = session_instance.session.message_nodes.get(&node_id) { - // Extract content from message - let content = match &node.message.content { - llm::MessageContent::Text(text) => text.clone(), - llm::MessageContent::Structured(blocks) => { - // Extract text content from structured blocks - blocks - .iter() - .filter_map(|block| match block { - llm::ContentBlock::Text { text, .. } => Some(text.clone()), - _ => None, - }) - .collect::>() - .join("\n") - } - }; - - // Extract attachments (images) from message - let attachments = match &node.message.content { - llm::MessageContent::Structured(blocks) => blocks - .iter() - .filter_map(|block| match block { - llm::ContentBlock::Image { - media_type, data, .. - } => Some(DraftAttachment::Image { - content: data.clone(), - mime_type: media_type.clone(), - width: None, - height: None, - }), - _ => None, - }) - .collect(), - _ => Vec::new(), - }; - - // The branch parent is the parent of the node being edited - let branch_parent_id = node.parent_id; - - // Generate truncated messages (up to but not including the message being edited) - let messages = session_instance - .convert_messages_to_ui_data_until( - session_instance.session.config.tool_syntax, - branch_parent_id, - ) - .unwrap_or_default(); - - let tool_results = session_instance - .convert_tool_executions_to_ui_data() - .unwrap_or_default(); - - Ok(( - content, - attachments, - branch_parent_id, - messages, - tool_results, - )) - } else { - Err(anyhow::anyhow!("Message node {} not found", node_id)) - } - } else { - Err(anyhow::anyhow!("Session {} not found", session_id)) - } - }; - - match result { - Ok((content, attachments, branch_parent_id, messages, tool_results)) => { - BackendResponse::MessageEditReady { - session_id: session_id.to_string(), - content, - attachments, - branch_parent_id, - messages, - tool_results, - } - } - Err(e) => { - error!("Failed to start message edit: {}", e); - BackendResponse::Error { - message: format!("Failed to start message edit: {e}"), - } - } - } -} - -async fn handle_switch_branch( - multi_session_manager: &Arc>, - session_id: &str, - new_node_id: crate::persistence::NodeId, -) -> BackendResponse { - debug!( - "Switching branch for session {} to node {}", - session_id, new_node_id - ); - - let mut manager = multi_session_manager.lock().await; - - let Some(session_instance) = manager.get_session_mut(session_id) else { - return BackendResponse::Error { - message: format!("Session {} not found", session_id), - }; - }; - - // Perform the branch switch - if let Err(e) = session_instance.session.switch_branch(new_node_id) { - error!("Failed to switch branch: {}", e); - return BackendResponse::Error { - message: format!("Failed to switch branch: {e}"), - }; - } - - // Persist the updated active_path - if let Err(e) = manager.save_session(session_id) { - error!("Failed to save session after branch switch: {}", e); - // Continue anyway - the switch worked in memory - } - - // Re-get session reference after save (borrow checker) - let Some(session_instance) = manager.get_session(session_id) else { - return BackendResponse::Error { - message: format!("Session {} not found after save", session_id), - }; - }; - - // Generate new messages for UI - let messages_data = match session_instance - .convert_messages_to_ui_data(session_instance.session.config.tool_syntax) - { - Ok(data) => data, - Err(e) => { - error!("Failed to convert messages: {}", e); - return BackendResponse::Error { - message: format!("Failed to convert messages: {e}"), - }; - } - }; - - let tool_results = match session_instance.convert_tool_executions_to_ui_data() { - Ok(results) => results, - Err(e) => { - error!("Failed to convert tool results: {}", e); - return BackendResponse::Error { - message: format!("Failed to convert tool results: {e}"), - }; - } - }; - - let plan = session_instance.session.plan.clone(); - - BackendResponse::BranchSwitched { - session_id: session_id.to_string(), - messages: messages_data, - tool_results, - plan, - } -} - -async fn handle_cancel_message_edit( - multi_session_manager: &Arc>, - session_id: &str, -) -> BackendResponse { - debug!("Cancelling message edit for session {}", session_id); - - let manager = multi_session_manager.lock().await; - - let Some(session_instance) = manager.get_session(session_id) else { - return BackendResponse::Error { - message: format!("Session {} not found", session_id), - }; - }; - - // Reload the current messages (restore full active path) - let messages_data = match session_instance - .convert_messages_to_ui_data(session_instance.session.config.tool_syntax) - { - Ok(data) => data, - Err(e) => { - error!("Failed to convert messages: {}", e); - return BackendResponse::Error { - message: format!("Failed to convert messages: {e}"), - }; - } - }; - - let tool_results = match session_instance.convert_tool_executions_to_ui_data() { - Ok(results) => results, - Err(e) => { - error!("Failed to convert tool results: {}", e); - return BackendResponse::Error { - message: format!("Failed to convert tool results: {e}"), - }; - } - }; - - BackendResponse::MessageEditCancelled { - session_id: session_id.to_string(), - messages: messages_data, - tool_results, - } -} - -// ============================================================================ -// Git Worktree Handlers -// ============================================================================ - -/// Resolve the project root path for a session (init_path, not worktree_path). -#[allow(clippy::result_large_err)] -fn get_session_project_root( - manager: &SessionManager, - session_id: &str, -) -> Result { - let session = manager - .get_session(session_id) - .ok_or_else(|| BackendResponse::Error { - message: format!("Session {session_id} not found"), - })?; - - session - .session - .config - .init_path - .clone() - .ok_or_else(|| BackendResponse::Error { - message: "Session has no project path configured".to_string(), - }) -} - -async fn handle_list_branches_and_worktrees( - multi_session_manager: &Arc>, - session_id: &str, -) -> BackendResponse { - debug!("Listing branches and worktrees for session {}", session_id); - - let project_root = { - let manager = multi_session_manager.lock().await; - match get_session_project_root(&manager, session_id) { - Ok(path) => path, - Err(resp) => return resp, - } - }; - - // Check if project is a git repo - if !git::GitRepository::is_repo(&project_root) { - return BackendResponse::BranchesAndWorktreesListed { - session_id: session_id.to_string(), - branches: Vec::new(), - worktrees: Vec::new(), - current_branch: None, - is_git_repo: false, - }; - } - - let repo = match git::GitRepository::open(&project_root) { - Ok(repo) => repo, - Err(e) => { - error!("Failed to open git repository: {}", e); - return BackendResponse::Error { - message: format!("Failed to open git repository: {e}"), - }; - } - }; - - let branches = match repo.list_branches() { - Ok(b) => b, - Err(e) => { - error!("Failed to list branches: {}", e); - return BackendResponse::Error { - message: format!("Failed to list branches: {e}"), - }; - } - }; - - let current_branch = repo.current_branch(); - - let worktrees = match git::worktree::list_worktrees(&repo).await { - Ok(w) => w, - Err(e) => { - error!("Failed to list worktrees: {}", e); - // Non-fatal: return branches without worktree info - Vec::new() - } - }; - - BackendResponse::BranchesAndWorktreesListed { - session_id: session_id.to_string(), - branches, - worktrees, - current_branch, - is_git_repo: true, - } -} - -async fn handle_switch_worktree( - multi_session_manager: &Arc>, - session_id: &str, - worktree_path: Option, - branch: Option, -) -> BackendResponse { - debug!( - "Switching worktree for session {}: path={:?}, branch={:?}", - session_id, worktree_path, branch - ); - - let result = { - let mut manager = multi_session_manager.lock().await; - manager.set_session_worktree(session_id, worktree_path.clone(), branch.clone()) - }; - - match result { - Ok(()) => { - info!( - "Successfully switched worktree for session {}: {:?}", - session_id, worktree_path - ); - BackendResponse::WorktreeSwitched { - session_id: session_id.to_string(), - worktree_path, - branch, - } - } - Err(e) => { - error!( - "Failed to switch worktree for session {}: {}", - session_id, e - ); - BackendResponse::Error { - message: format!("Failed to switch worktree: {e}"), - } - } - } -} - -async fn handle_create_worktree( - multi_session_manager: &Arc>, - session_id: &str, - branch_name: &str, - base_branch: Option<&str>, -) -> BackendResponse { - debug!( - "Creating worktree for session {}: branch={}, base={:?}", - session_id, branch_name, base_branch - ); - - let project_root = { - let manager = multi_session_manager.lock().await; - match get_session_project_root(&manager, session_id) { - Ok(path) => path, - Err(resp) => return resp, - } - }; - - let repo = match git::GitRepository::open(&project_root) { - Ok(repo) => repo, - Err(e) => { - return BackendResponse::Error { - message: format!("Failed to open git repository: {e}"), - }; - } - }; - - // Check if a worktree for this branch already exists - match git::worktree::find_worktree_for_branch(&repo, branch_name).await { - Ok(Some(existing)) => { - info!( - "Reusing existing worktree for branch '{}' at {:?}", - branch_name, existing.path - ); - // Reuse existing worktree — just switch the session to it - let result = { - let mut manager = multi_session_manager.lock().await; - manager.set_session_worktree( - session_id, - Some(existing.path.clone()), - Some(branch_name.to_string()), - ) - }; - return match result { - Ok(()) => BackendResponse::WorktreeCreated { - session_id: session_id.to_string(), - worktree_path: existing.path, - branch: branch_name.to_string(), - }, - Err(e) => BackendResponse::Error { - message: format!("Failed to set worktree on session: {e}"), - }, - }; - } - Ok(None) => {} // No existing worktree, create one - Err(e) => { - debug!("Could not check existing worktrees: {}", e); - // Continue with creation attempt - } - } - - let worktree_path = git::worktree::suggest_worktree_path(repo.workdir(), branch_name); - - match git::worktree::create_worktree( - &repo.git, - repo.workdir(), - &worktree_path, - branch_name, - base_branch, - ) - .await - { - Ok(canonical_path) => { - info!( - "Created worktree at {:?} for branch '{}'", - canonical_path, branch_name - ); - - // Update the session to use this worktree - let result = { - let mut manager = multi_session_manager.lock().await; - manager.set_session_worktree( - session_id, - Some(canonical_path.clone()), - Some(branch_name.to_string()), - ) - }; - - match result { - Ok(()) => BackendResponse::WorktreeCreated { - session_id: session_id.to_string(), - worktree_path: canonical_path, - branch: branch_name.to_string(), - }, - Err(e) => BackendResponse::Error { - message: format!("Worktree created but failed to update session: {e}"), - }, - } - } - Err(e) => { - error!("Failed to create worktree: {}", e); - BackendResponse::Error { - message: format!("Failed to create worktree: {e}"), - } - } - } -} - -// ============================================================================ -// Project Management Handlers -// ============================================================================ - -async fn handle_add_project( - multi_session_manager: &Arc>, - name: &str, - path: &PathBuf, -) -> BackendResponse { - info!("Adding project '{}' at {:?}", name, path); - - // Check if this project already exists with the same name and path - if let Ok(existing_projects) = crate::config::load_projects() { - if let Some(existing) = existing_projects.get(name) { - // Canonicalize both paths for comparison - let existing_canonical = existing.path.canonicalize().ok(); - let new_canonical = path.canonicalize().ok(); - let paths_match = match (&existing_canonical, &new_canonical) { - (Some(a), Some(b)) => a == b, - _ => existing.path == *path, - }; - if paths_match { - info!( - "Project '{}' already exists with the same path — no-op", - name - ); - return BackendResponse::ProjectAlreadyExists { - project_name: name.to_string(), - }; - } - } - } - - // Save to projects.json - let project = Project { - path: path.clone(), - format_on_save: None, - }; - if let Err(e) = save_project(name, &project) { - error!("Failed to save project to config: {}", e); - return BackendResponse::Error { - message: format!("Failed to save project: {e}"), - }; - } - - // Create an initial session for the new project - let create_result = { - let mut manager = multi_session_manager.lock().await; - let mut config = manager.session_config_template().clone(); - config.initial_project = name.to_string(); - config.init_path = Some(path.clone()); - manager.create_session_with_config(None, Some(config), None) - }; - - match create_result { - Ok(session_id) => { - info!( - "Created initial session {} for project '{}'", - session_id, name - ); - BackendResponse::ProjectAdded { - project_name: name.to_string(), - session_id, - } - } - Err(e) => { - // Project was saved but session creation failed - error!("Project saved but failed to create session: {}", e); - BackendResponse::Error { - message: format!("Project saved but failed to create session: {e}"), - } - } - } -} - -/// Persist a temporary project to projects.json. -/// -/// Resolves the project path from existing sessions and writes it to the -/// config file so the project becomes a first-class entry visible to all -/// sessions. -async fn handle_persist_project( - multi_session_manager: &Arc>, - project_name: &str, -) -> BackendResponse { - info!("Persisting temporary project '{}'", project_name); - - let path = { - let manager = multi_session_manager.lock().await; - manager.resolve_project_path(project_name) - }; - - let Some(path) = path else { - return BackendResponse::Error { - message: format!( - "Cannot persist project '{}': unable to determine its path", - project_name - ), - }; - }; - - let project = Project { - path, - format_on_save: None, - }; - if let Err(e) = save_project(project_name, &project) { - error!("Failed to persist project '{}': {}", project_name, e); - return BackendResponse::Error { - message: format!("Failed to persist project: {e}"), - }; - } - - info!("Project '{}' persisted to projects.json", project_name); - BackendResponse::ProjectPersisted { - project_name: project_name.to_string(), - } -} diff --git a/crates/code_assistant_core/src/config.rs b/crates/code_assistant_core/src/config.rs index b1929b1c..d79c5a1e 100644 --- a/crates/code_assistant_core/src/config.rs +++ b/crates/code_assistant_core/src/config.rs @@ -45,19 +45,46 @@ pub const SCOPE_CONFIG: &str = ":config:"; /// Reserved scope token addressing the bundled (system) skills directory. pub const SCOPE_SYSTEM: &str = ":system:"; -/// Resolve a reserved skills-scope token to its sandboxed root directory, -/// relative to `config_dir`. Returns `None` for ordinary project names. +/// Environment variable that overrides the user (global) skills root. Primarily +/// for tests and power users who want a non-default location. +pub const USER_SKILLS_DIR_ENV: &str = "CODE_ASSISTANT_USER_SKILLS_DIR"; + +/// The shared, cross-harness **user** skills root: `~/.agents/skills` by +/// default (the same convention codex and Claude Code use), so user-authored +/// skills can be shared across agent harnesses. Deliberately **not** under the +/// config directory. Overridable via [`USER_SKILLS_DIR_ENV`]. /// -/// - [`SCOPE_CONFIG`] (`:config:`) → `/skills` (user skills) -/// - [`SCOPE_SYSTEM`] (`:system:`) → `/skills/.system` (bundled skills) +/// Note: we never create this directory; it is only scanned when it exists. +pub fn user_skills_root() -> PathBuf { + if let Ok(dir) = std::env::var(USER_SKILLS_DIR_ENV) { + if !dir.is_empty() { + return PathBuf::from(dir); + } + } + dirs::home_dir() + .map(|home| home.join(".agents").join("skills")) + .unwrap_or_else(|| PathBuf::from(".agents").join("skills")) +} + +/// The bundled (system) skills root: `/skills/.system`. This tree +/// is managed by us — the bundled skills are extracted here on startup. +pub fn system_skills_root(config_dir: &Path) -> PathBuf { + config_dir.join("skills").join(".system") +} + +/// Resolve a reserved skills-scope token to its sandboxed root directory. +/// Returns `None` for ordinary project names. /// -/// The roots are deliberately the `skills` subtree, never `config_dir` itself — -/// the config directory also holds secrets (e.g. `providers.json`), which must -/// stay unreachable through file tools. +/// - [`SCOPE_CONFIG`] (`:config:`) → [`user_skills_root`] (`~/.agents/skills`) +/// - [`SCOPE_SYSTEM`] (`:system:`) → `/skills/.system` (bundled) +/// +/// The system root is deliberately the `skills/.system` subtree, never +/// `config_dir` itself — the config directory also holds secrets (e.g. +/// `providers.json`), which must stay unreachable through file tools. pub fn skills_scope_root(scope: &str, config_dir: &Path) -> Option { match scope { - SCOPE_CONFIG => Some(config_dir.join("skills")), - SCOPE_SYSTEM => Some(config_dir.join("skills").join(".system")), + SCOPE_CONFIG => Some(user_skills_root()), + SCOPE_SYSTEM => Some(system_skills_root(config_dir)), _ => None, } } @@ -245,13 +272,31 @@ mod tests { use super::*; use tempfile::tempdir; + /// Serializes tests that mutate the process-global user-skills env var. + static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + /// Set the user-skills override for the duration of `f`, restoring it after. + fn with_user_skills_root(path: &Path, f: impl FnOnce() -> R) -> R { + let _guard = ENV_LOCK.lock().unwrap(); + let previous = std::env::var(USER_SKILLS_DIR_ENV).ok(); + std::env::set_var(USER_SKILLS_DIR_ENV, path); + let result = f(); + match previous { + Some(value) => std::env::set_var(USER_SKILLS_DIR_ENV, value), + None => std::env::remove_var(USER_SKILLS_DIR_ENV), + } + result + } + #[test] fn skills_scope_root_maps_reserved_tokens_only() { let cfg = Path::new("/cfg"); - assert_eq!( - skills_scope_root(SCOPE_CONFIG, cfg), - Some(PathBuf::from("/cfg/skills")) - ); + with_user_skills_root(Path::new("/user-skills"), || { + assert_eq!( + skills_scope_root(SCOPE_CONFIG, cfg), + Some(PathBuf::from("/user-skills")) + ); + }); assert_eq!( skills_scope_root(SCOPE_SYSTEM, cfg), Some(PathBuf::from("/cfg/skills/.system")) @@ -264,6 +309,7 @@ mod tests { #[test] fn explorer_for_scope_in_resolves_reserved_tokens() { let config_dir = tempdir().unwrap(); + let user_skills = tempdir().unwrap(); let pm = crate::mocks::MockProjectManager::default(); let system = explorer_for_scope_in(&pm, SCOPE_SYSTEM, config_dir.path()).unwrap(); @@ -272,8 +318,12 @@ mod tests { config_dir.path().join("skills").join(".system") ); - let user = explorer_for_scope_in(&pm, SCOPE_CONFIG, config_dir.path()).unwrap(); - assert_eq!(user.root_dir(), config_dir.path().join("skills")); + with_user_skills_root(user_skills.path(), || { + let user = explorer_for_scope_in(&pm, SCOPE_CONFIG, config_dir.path()).unwrap(); + // The explorer canonicalizes its root; compare canonicalized paths. + let expected = user_skills.path().canonicalize().unwrap(); + assert_eq!(user.root_dir(), expected); + }); } #[test] @@ -293,16 +343,15 @@ mod tests { } #[tokio::test] - async fn config_scope_cannot_escape_to_config_secrets() { - // Lay out a config dir with secrets next to the skills subtree. - let config_dir = tempdir().unwrap(); + async fn skills_scope_cannot_escape_its_root() { + // Lay out a directory with a secret next to the skills root. + let base = tempdir().unwrap(); std::fs::write( - config_dir.path().join("providers.json"), + base.path().join("providers.json"), "{\"api_key\":\"super-secret\"}", ) .unwrap(); - - let skills_root = skills_scope_root(SCOPE_CONFIG, config_dir.path()).unwrap(); + let skills_root = base.path().join("skills"); std::fs::create_dir_all(&skills_root).unwrap(); let explorer = Explorer::new(skills_root.clone()); @@ -312,7 +361,7 @@ mod tests { let result = explorer.read_file(&escape).await; assert!( result.is_err(), - "config scope must not be able to read outside the skills subtree" + "a skills scope must not be able to read outside its sandboxed root" ); } } diff --git a/crates/code_assistant_core/src/lib.rs b/crates/code_assistant_core/src/lib.rs index 98c2d532..62f29070 100644 --- a/crates/code_assistant_core/src/lib.rs +++ b/crates/code_assistant_core/src/lib.rs @@ -7,7 +7,6 @@ //! binary consume this crate; nothing in here depends on a frontend. pub mod agent; -pub mod backend; pub mod config; pub mod config_dir; pub mod persistence; diff --git a/crates/code_assistant_core/src/mocks.rs b/crates/code_assistant_core/src/mocks.rs index 21a8eef8..3f7577e3 100644 --- a/crates/code_assistant_core/src/mocks.rs +++ b/crates/code_assistant_core/src/mocks.rs @@ -293,10 +293,6 @@ impl UserInterface for MockUI { fn clear_rate_limit(&self) { // Mock implementation does nothing with rate limit clearing } - - fn as_any(&self) -> &dyn std::any::Any { - self - } } impl MockUI { diff --git a/crates/code_assistant_core/src/persistence.rs b/crates/code_assistant_core/src/persistence.rs index d9ddb491..c0d4f720 100644 --- a/crates/code_assistant_core/src/persistence.rs +++ b/crates/code_assistant_core/src/persistence.rs @@ -993,15 +993,19 @@ impl FileSessionPersistence { /// Generate a unique session ID pub fn generate_session_id() -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + // Process-local counter so IDs generated within the same second (the + // timestamp's resolution) stay unique. + static COUNTER: AtomicU64 = AtomicU64::new(0); + let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); + let pid_part = std::process::id() as u64 % 1000; + let counter = COUNTER.fetch_add(1, Ordering::Relaxed); - // Simple random component using timestamp - let random_part = (timestamp % 10000) + (std::process::id() as u64 % 1000); - - format!("chat_{timestamp:x}_{random_part:x}") + format!("chat_{timestamp:x}_{pid_part:x}_{counter:x}") } /// Calculate usage information from session messages. diff --git a/crates/code_assistant_core/src/session/event_stream.rs b/crates/code_assistant_core/src/session/event_stream.rs new file mode 100644 index 00000000..e4d7f8b6 --- /dev/null +++ b/crates/code_assistant_core/src/session/event_stream.rs @@ -0,0 +1,196 @@ +//! The core→UI broadcast stream. +//! +//! Everything the core wants frontends to know — streaming fragments, +//! state changes, notifications — is *published* here, tagged with the +//! session it belongs to. Frontends [`subscribe`](EventStream::subscribe) +//! and decide themselves what to render; the core does not know which +//! session is being viewed (or how many views exist). +//! +//! Delivery is best-effort with bounded buffering: a subscriber that falls +//! behind observes [`StreamError::Lagged`] and is expected to resync by +//! calling `SessionService::load_session` for a fresh snapshot, then +//! continue consuming. + +use crate::ui::{DisplayFragment, UiEvent}; +use tokio::sync::broadcast; + +/// One item on the core→UI stream. +#[derive(Debug, Clone)] +pub struct SessionEvent { + /// The session this event belongs to; `None` for app-scoped events + /// (chat list updates, config changes). + pub session_id: Option, + pub payload: EventPayload, +} + +#[derive(Debug, Clone)] +pub enum EventPayload { + /// A streaming display fragment of a session's in-flight assistant + /// response. + Fragment(DisplayFragment), + /// An application notification. + Ui(UiEvent), +} + +/// Why a subscription stopped yielding events. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StreamError { + /// The subscriber fell behind and `missed` events were dropped. The + /// subscription is still usable; resync via a fresh session snapshot. + Lagged { missed: u64 }, + /// The core shut down; no more events will arrive. + Closed, +} + +/// Cloneable publisher handle for the core→UI stream. +#[derive(Clone)] +pub struct EventStream { + sender: broadcast::Sender, +} + +/// Number of events buffered per lagging subscriber before drops occur. +/// Streaming produces many small fragment events, so this is sized +/// generously; a lagged subscriber resyncs via snapshot, so drops are +/// recoverable. +const CHANNEL_CAPACITY: usize = 4096; + +impl Default for EventStream { + fn default() -> Self { + Self::new() + } +} + +impl EventStream { + pub fn new() -> Self { + let (sender, _) = broadcast::channel(CHANNEL_CAPACITY); + Self { sender } + } + + pub fn subscribe(&self) -> Subscription { + Subscription { + receiver: self.sender.subscribe(), + } + } + + /// Publish an event. Never blocks; if no subscriber exists the event is + /// dropped (frontends resync via snapshot when they attach). + pub fn publish(&self, session_id: Option, payload: EventPayload) { + let _ = self.sender.send(SessionEvent { + session_id, + payload, + }); + } + + /// Publish a session-scoped notification. + pub fn publish_ui(&self, session_id: impl Into, event: UiEvent) { + self.publish(Some(session_id.into()), EventPayload::Ui(event)); + } + + /// Publish an app-scoped notification. + pub fn publish_app(&self, event: UiEvent) { + self.publish(None, EventPayload::Ui(event)); + } +} + +/// A frontend's subscription to the core→UI stream. +pub struct Subscription { + receiver: broadcast::Receiver, +} + +impl Subscription { + /// Wait for the next event. + pub async fn recv(&mut self) -> Result { + match self.receiver.recv().await { + Ok(event) => Ok(event), + Err(broadcast::error::RecvError::Lagged(missed)) => Err(StreamError::Lagged { missed }), + Err(broadcast::error::RecvError::Closed) => Err(StreamError::Closed), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn events_reach_all_subscribers() { + let stream = EventStream::new(); + let mut a = stream.subscribe(); + let mut b = stream.subscribe(); + + stream.publish_ui("s1", UiEvent::ClearMessages); + + for subscription in [&mut a, &mut b] { + let event = subscription.recv().await.unwrap(); + assert_eq!(event.session_id.as_deref(), Some("s1")); + assert!(matches!( + event.payload, + EventPayload::Ui(UiEvent::ClearMessages) + )); + } + } + + #[tokio::test] + async fn app_scoped_events_have_no_session() { + let stream = EventStream::new(); + let mut sub = stream.subscribe(); + stream.publish_app(UiEvent::ConfigChanged); + assert_eq!(sub.recv().await.unwrap().session_id, None); + } + + #[tokio::test] + async fn fragments_are_session_tagged() { + let stream = EventStream::new(); + let mut sub = stream.subscribe(); + stream.publish( + Some("s1".to_string()), + EventPayload::Fragment(DisplayFragment::PlainText("hi".to_string())), + ); + let event = sub.recv().await.unwrap(); + assert_eq!(event.session_id.as_deref(), Some("s1")); + assert!(matches!( + event.payload, + EventPayload::Fragment(DisplayFragment::PlainText(ref t)) if t == "hi" + )); + } + + #[tokio::test] + async fn publish_without_subscribers_is_a_noop() { + let stream = EventStream::new(); + stream.publish_app(UiEvent::ConfigChanged); + // A later subscriber does not see earlier events… + let mut sub = stream.subscribe(); + stream.publish_app(UiEvent::ClearError); + // …only the ones published after subscribing. + assert!(matches!( + sub.recv().await.unwrap().payload, + EventPayload::Ui(UiEvent::ClearError) + )); + } + + #[tokio::test] + async fn slow_subscriber_observes_lag_and_can_continue() { + let stream = EventStream::new(); + let mut sub = stream.subscribe(); + + // Overflow the per-subscriber buffer. + for _ in 0..(CHANNEL_CAPACITY + 10) { + stream.publish_app(UiEvent::ConfigChanged); + } + + match sub.recv().await { + Err(StreamError::Lagged { missed }) => assert!(missed >= 10), + other => panic!("expected lag, got {other:?}"), + } + // The subscription keeps working after the lag signal. + assert!(sub.recv().await.is_ok()); + } + + #[tokio::test] + async fn closed_stream_reports_closed() { + let stream = EventStream::new(); + let mut sub = stream.subscribe(); + drop(stream); + assert!(matches!(sub.recv().await, Err(StreamError::Closed))); + } +} diff --git a/crates/code_assistant_core/src/session/instance.rs b/crates/code_assistant_core/src/session/instance.rs index 0e7688c3..1257980b 100644 --- a/crates/code_assistant_core/src/session/instance.rs +++ b/crates/code_assistant_core/src/session/instance.rs @@ -49,6 +49,90 @@ impl SessionActivityState { } } +/// Shared handle to a session's activity state that owns the transition +/// rules. The [`SessionEventPublisher`] reports streaming lifecycle moments +/// through the `on_*` methods and publishes whatever state change they +/// return — what a moment *means* for the state is decided here. +#[derive(Clone, Default)] +pub struct SessionActivity { + state: Arc>, +} + +impl SessionActivity { + pub fn get(&self) -> SessionActivityState { + self.state.lock().unwrap().clone() + } + + /// Set the state unconditionally. Reserved for the agent lifecycle + /// itself (start, completion, error) — the only places allowed to leave + /// a terminal state. + pub fn set(&self, state: SessionActivityState) { + *self.state.lock().unwrap() = state; + } + + /// Apply a transition respecting the terminal-state rule: terminal + /// states (Idle, Errored) persist until a new agent is explicitly + /// started via [`SessionActivity::set`]. Returns the new state if it + /// changed, so the caller knows whether to broadcast. + pub fn try_transition(&self, new_state: SessionActivityState) -> Option { + let mut state = self.state.lock().unwrap(); + if state.is_terminal() && !new_state.is_terminal() { + debug!( + "Ignoring activity transition from {:?} to {:?}", + *state, new_state + ); + return None; + } + if *state == new_state { + return None; + } + *state = new_state.clone(); + Some(new_state) + } + + /// An LLM request was sent and the response hasn't started streaming. + pub fn on_streaming_started(&self) -> Option { + self.try_transition(SessionActivityState::WaitingForResponse) + } + + /// Streaming ended. Moves back to AgentRunning on success; a cancelled + /// or failed request leaves the state untouched (the agent task decides + /// the final state), as does an agent that already completed. + pub fn on_streaming_stopped( + &self, + cancelled: bool, + errored: bool, + ) -> Option { + if cancelled || errored { + return None; + } + match self.get() { + SessionActivityState::WaitingForResponse | SessionActivityState::RateLimited { .. } => { + self.try_transition(SessionActivityState::AgentRunning) + } + _ => None, + } + } + + /// The stream produced its first visible output. + pub fn on_visible_output(&self) -> Option { + match self.get() { + SessionActivityState::WaitingForResponse => { + self.try_transition(SessionActivityState::AgentRunning) + } + _ => None, + } + } + + pub fn on_rate_limited(&self, seconds_remaining: u64) -> Option { + self.try_transition(SessionActivityState::RateLimited { seconds_remaining }) + } + + pub fn on_rate_limit_cleared(&self) -> Option { + self.try_transition(SessionActivityState::WaitingForResponse) + } +} + /// Buffered tool-status update received while the session was disconnected. /// Keyed by `tool_id` so only the most recent status per tool is retained. type ToolStatusBuffer = HashMap; @@ -63,21 +147,27 @@ pub struct SessionInstance { /// Task handle for the running agent (None if not running) pub task_handle: Option>>, - /// Buffer for DisplayFragments from the current streaming message - /// This allows UI to connect mid-streaming and see buffered content + /// In-flight DisplayFragments of the currently streaming response. + /// Written by the [`SessionEventPublisher`]; included in snapshots so a + /// frontend connecting mid-stream sees the partial message. pub fragment_buffer: Arc>>, - /// Buffer for `UpdateToolStatus` events received while the session is - /// disconnected. Shared with the session's [`ProxyUI`] which writes into - /// it; read (and drained) by [`generate_session_connect_events`] on - /// reconnect. Only the latest status per tool-id is kept. + /// The pre-allocated node id of the currently streaming response (from + /// `StreamingStarted`). Snapshots tag the partial message with it so + /// frontends can deduplicate against the persisted message later. + pub in_flight_node_id: Arc>>, + + /// Latest live `UpdateToolStatus` per tool of the current agent run. + /// Written by the [`SessionEventPublisher`]; merged into snapshots + /// (persisted results take precedence). Cleared on agent start. pub tool_status_buffer: Arc>, - /// Whether this session is currently connected to the UI - pub is_ui_connected: Arc>, + /// Current activity state of this session (shared with the publisher) + pub activity: SessionActivity, - /// Current activity state of this session - pub activity_state: Arc>, + /// Set when a user requests the running agent to stop; checked by the + /// agent at streaming checkpoints. Cleared when a new agent starts. + pub stop_requested: Arc, /// Pending user message (structured content blocks) that will be processed by the next agent iteration pub pending_message: Arc>>>, @@ -129,8 +219,9 @@ impl SessionInstance { task_handle: None, fragment_buffer: Arc::new(Mutex::new(VecDeque::new())), tool_status_buffer: Arc::new(Mutex::new(HashMap::new())), - is_ui_connected: Arc::new(Mutex::new(false)), - activity_state: Arc::new(Mutex::new(SessionActivityState::Idle)), + in_flight_node_id: Arc::new(Mutex::new(None)), + activity: SessionActivity::default(), + stop_requested: Arc::new(std::sync::atomic::AtomicBool::new(false)), pending_message: Arc::new(Mutex::new(None)), sandbox_context, sub_agent_cancellation_registry: Arc::new(SubAgentCancellationRegistry::default()), @@ -147,14 +238,30 @@ impl SessionInstance { self.sub_agent_cancellation_registry.cancel(tool_id) } + /// Ask the running agent to stop at its next streaming checkpoint. + pub fn request_stop(&self) { + self.stop_requested + .store(true, std::sync::atomic::Ordering::Relaxed); + } + + /// Reset per-run state when a new agent starts: clears a previous stop + /// request and the live tool-status map of the prior run. + pub fn begin_agent_run(&self) { + self.stop_requested + .store(false, std::sync::atomic::Ordering::Relaxed); + if let Ok(mut buf) = self.tool_status_buffer.lock() { + buf.clear(); + } + } + /// Get the current activity state pub fn get_activity_state(&self) -> SessionActivityState { - self.activity_state.lock().unwrap().clone() + self.activity.get() } /// Set the activity state pub fn set_activity_state(&self, state: SessionActivityState) { - *self.activity_state.lock().unwrap() = state; + self.activity.set(state); } /// Get all buffered fragments and optionally clear the buffer @@ -271,23 +378,22 @@ impl SessionInstance { Ok(()) } - /// Set UI active state for this session - pub fn set_ui_connected(&mut self, connected: bool) { - if let Ok(mut ui_connected) = self.is_ui_connected.lock() { - *ui_connected = connected; - } - } - - /// Create a ProxyUI for this session that handles fragment buffering - pub fn create_proxy_ui(&self, real_ui: Arc) -> Arc { - Arc::new(ProxyUI::new( - real_ui, - self.fragment_buffer.clone(), - self.tool_status_buffer.clone(), - self.is_ui_connected.clone(), - self.activity_state.clone(), - self.session.id.clone(), - )) + /// Create the publisher this session's agent talks to: it records + /// in-flight state for snapshots and publishes everything session-tagged + /// to the core→UI broadcast stream. + pub fn create_publisher( + &self, + events: crate::session::event_stream::EventStream, + ) -> Arc { + Arc::new(SessionEventPublisher { + events, + fragment_buffer: self.fragment_buffer.clone(), + in_flight_node_id: self.in_flight_node_id.clone(), + tool_status_buffer: self.tool_status_buffer.clone(), + activity: self.activity.clone(), + stop_requested: self.stop_requested.clone(), + session_id: self.session.id.clone(), + }) } /// Generate UI events for connecting to this session. @@ -297,68 +403,43 @@ impl SessionInstance { /// up to and including that node. This is used to restore the "edit mode" /// view (truncated to the branch parent) directly when connecting to a /// session whose draft is in edit mode, avoiding a full-then-truncate flash. - pub fn generate_session_connect_events( + pub fn build_snapshot( &self, until_node_id: Option, - ) -> Result, anyhow::Error> { - let mut events = Vec::new(); - + ) -> Result { // Convert session messages to UI data (optionally truncated for edit mode) - let mut messages_data = + let mut messages = self.convert_messages_to_ui_data_until(self.session.config.tool_syntax, until_node_id)?; let mut tool_results = self.convert_tool_executions_to_ui_data()?; - // Drain any UpdateToolStatus events that arrived while we were - // disconnected (e.g. from running sub-agents). Only inject entries - // that don't already have a persisted result — the persisted result - // is authoritative once it exists. - if let Ok(mut buf) = self.tool_status_buffer.lock() { - for (_tool_id, result_data) in buf.drain() { + // Merge in the latest live tool statuses (e.g. from running + // sub-agents). Only inject entries that don't already have a + // persisted result — the persisted result is authoritative once it + // exists. + if let Ok(buf) = self.tool_status_buffer.lock() { + for result_data in buf.values() { if !tool_results .iter() .any(|r| r.tool_id == result_data.tool_id) { - tool_results.push(result_data); + tool_results.push(result_data.clone()); } } } - // If currently streaming, add incomplete message as additional MessageData + // If currently streaming, add the incomplete message as additional + // MessageData, tagged with the pre-allocated node id so frontends can + // deduplicate it against the persisted message later. let buffered_fragments = self.get_buffered_fragments(false); // Don't clear buffer if !buffered_fragments.is_empty() { - // Create incomplete assistant message from buffered fragments - let incomplete_message = MessageData { + messages.push(MessageData { role: MessageRole::Assistant, fragments: buffered_fragments, - node_id: None, // Streaming message doesn't have a node yet + node_id: self.in_flight_node_id.lock().ok().and_then(|id| *id), branch_info: None, // No branch info for incomplete message - }; - messages_data.push(incomplete_message); - } - - events.push(UiEvent::SetMessages { - messages: messages_data, - session_id: Some(self.session.id.clone()), - tool_results, - }); - - events.push(UiEvent::UpdatePlan { - plan: self.session.plan.clone(), - }); - - events.push(UiEvent::UpdateSessionActivityState { - session_id: self.session.id.clone(), - activity_state: self.get_activity_state(), - }); - - // If the session is in an errored state, emit a DisplayError so the - // error banner is shown when the user switches to this session. - if let SessionActivityState::Errored { message } = self.get_activity_state() { - events.push(UiEvent::DisplayError { message }); + }); } - // Add session metadata to ensure UI has the session info including initial_project - let metadata = ChatMetadata { id: self.session.id.clone(), name: self.session.name.clone(), @@ -376,17 +457,25 @@ impl SessionInstance { is_resumable: self.session.is_resumable(), }; - events.push(UiEvent::UpdateSessionMetadata { metadata }); - - if let Ok(pending) = self.pending_message.lock() { - events.push(UiEvent::UpdatePendingMessage { - message: pending - .as_ref() - .map(|blocks| crate::utils::content::text_summary_from_blocks(blocks)), - }); - } + let pending_message = self.pending_message.lock().ok().and_then(|pending| { + pending + .as_ref() + .map(|blocks| crate::utils::content::text_summary_from_blocks(blocks)) + }); - Ok(events) + Ok(crate::session::SessionSnapshot { + session_id: self.session.id.clone(), + messages, + tool_results, + plan: self.session.plan.clone(), + activity_state: self.get_activity_state(), + metadata, + pending_message, + // Filled in by the SessionManager, which owns model resolution. + current_model: String::new(), + allowed_models: Vec::new(), + sandbox_policy: self.session.config.sandbox_policy.clone(), + }) } /// Convert session messages to UI MessageData format @@ -427,9 +516,6 @@ impl SessionInstance { } fn notify_rate_limit(&self, _seconds_remaining: u64) {} fn clear_rate_limit(&self) {} - fn as_any(&self) -> &dyn std::any::Any { - self - } } let dummy_ui: std::sync::Arc = std::sync::Arc::new(DummyUI); @@ -561,9 +647,6 @@ impl SessionInstance { } fn notify_rate_limit(&self, _seconds_remaining: u64) {} fn clear_rate_limit(&self) {} - fn as_any(&self) -> &dyn std::any::Any { - self - } } let dummy_ui: std::sync::Arc = std::sync::Arc::new(DummyUI); @@ -734,150 +817,101 @@ impl SessionInstance { } } -/// ProxyUI that buffers fragments and conditionally forwards to real UI -struct ProxyUI { - real_ui: Arc, +/// The session's publisher onto the core→UI broadcast stream, implementing +/// [`UserInterface`] for the agent seam. +/// +/// It owns no state logic: activity transitions are decided by the shared +/// [`SessionActivity`] handle; in-flight fragments and live tool statuses +/// are recorded as session state so snapshots can include them. Which +/// frontend (if any) renders the published events is not its concern. +struct SessionEventPublisher { + events: crate::session::event_stream::EventStream, + /// In-flight fragments of the currently streaming response, kept for + /// snapshots (the content is not persisted until the message completes). fragment_buffer: Arc>>, - /// Buffers `UpdateToolStatus` events received while disconnected so they - /// can be replayed on the next session reconnect. + /// Pre-allocated node id of the in-flight response (see + /// [`SessionInstance::in_flight_node_id`]). + in_flight_node_id: Arc>>, + /// Latest live status per tool of the current agent run, kept for + /// snapshots (persisted results take precedence when merging). tool_status_buffer: Arc>, - is_session_connected: Arc>, - session_activity_state: Arc>, + activity: SessionActivity, + stop_requested: Arc, session_id: String, } -impl ProxyUI { - pub fn new( - real_ui: Arc, - fragment_buffer: Arc>>, - tool_status_buffer: Arc>, - is_session_connected: Arc>, - session_activity_state: Arc>, - session_id: String, - ) -> Self { - Self { - real_ui, - fragment_buffer, - tool_status_buffer, - is_session_connected, - session_activity_state, - session_id, - } - } - - /// Check if this session is currently connected to the real UI - fn is_connected(&self) -> bool { - self.is_session_connected - .lock() - .map(|active| *active) - .unwrap_or(false) - } - - /// Update activity state and broadcast the change to the UI - fn update_activity_state(&self, new_state: SessionActivityState) { - // Update our internal state - if let Ok(mut state) = self.session_activity_state.lock() { - // Don't allow transitions from terminal states (Idle, Errored) back to other states. - // Terminal states persist until a new agent is explicitly started. - if state.is_terminal() && !new_state.is_terminal() { - debug!( - "Ignoring state transition from {:?} to {:?} for session {}", - *state, new_state, self.session_id - ); - return; - } - - if *state != new_state { - *state = new_state.clone(); - - // Always broadcast activity state changes to UI (regardless of connection status) - // This ensures the chat sidebar shows current activity for all sessions - // Send synchronously to avoid race conditions with async task spawning - if let Ok(handle) = tokio::runtime::Handle::try_current() { - let ui = self.real_ui.clone(); - let session_id = self.session_id.clone(); - let activity_state = new_state; - handle.spawn(async move { - let _ = ui - .send_event(UiEvent::UpdateSessionActivityState { - session_id, - activity_state, - }) - .await; - }); - } - } - } +impl SessionEventPublisher { + /// Publish an activity-state change produced by [`SessionActivity`]. + fn publish_activity_change(&self, change: Option) { + let Some(activity_state) = change else { + return; + }; + self.events.publish_ui( + &self.session_id, + UiEvent::UpdateSessionActivityState { + session_id: self.session_id.clone(), + activity_state, + }, + ); } } #[async_trait] -impl UserInterface for ProxyUI { +impl UserInterface for SessionEventPublisher { async fn send_event(&self, event: UiEvent) -> Result<(), UIError> { // Handle special events that need buffer management and activity state updates match &event { - UiEvent::StreamingStarted { .. } => { - // Clear fragment buffer at start of new LLM request + UiEvent::StreamingStarted { node_id, .. } => { + // Reset the in-flight state for the new LLM request if let Ok(mut buffer) = self.fragment_buffer.lock() { buffer.clear(); } - // Update activity state to waiting for response - self.update_activity_state(SessionActivityState::WaitingForResponse); + if let Ok(mut in_flight) = self.in_flight_node_id.lock() { + *in_flight = Some(*node_id); + } + self.publish_activity_change(self.activity.on_streaming_started()); } UiEvent::StreamingStopped { cancelled, error, .. } => { - // Clear fragment buffer when LLM request ends - fragments are now part of message history + // Clear the in-flight state when the LLM request ends — + // fragments are now part of message history if let Ok(mut buffer) = self.fragment_buffer.lock() { buffer.clear(); } - // Only update activity state back to agent running if streaming was not cancelled - // and there was no error, and the agent hasn't already completed (i.e., state is not already Idle) - if !cancelled && error.is_none() { - let current_state = self - .session_activity_state - .lock() - .map(|s| s.clone()) - .unwrap_or(SessionActivityState::Idle); - if matches!( - current_state, - SessionActivityState::WaitingForResponse - | SessionActivityState::RateLimited { .. } - ) { - self.update_activity_state(SessionActivityState::AgentRunning); - } - } else if let Some(error_msg) = error { - // If there was an error, the agent will terminate, so don't transition to AgentRunning + if let Ok(mut in_flight) = self.in_flight_node_id.lock() { + *in_flight = None; + } + if let Some(error_msg) = error { + // The agent task will set the final state when it terminates debug!( "StreamingStopped with error for session {}: {}", self.session_id, error_msg ); - // The agent task will set the state to Idle when it terminates } + self.publish_activity_change( + self.activity + .on_streaming_stopped(*cancelled, error.is_some()), + ); } UiEvent::RollbackStreaming { .. } => { - // Clear fragment buffer — the partial content is being discarded before a retry + // Discard the in-flight state — the partial content is being + // discarded before a retry if let Ok(mut buffer) = self.fragment_buffer.lock() { buffer.clear(); } + if let Ok(mut in_flight) = self.in_flight_node_id.lock() { + *in_flight = None; + } } UiEvent::UpdateSessionActivityState { session_id, activity_state, } if session_id == &self.session_id => { - self.update_activity_state(activity_state.clone()); + self.publish_activity_change(self.activity.try_transition(activity_state.clone())); return Ok(()); } - _ => {} - } - - if self.is_connected() { - self.real_ui.send_event(event).await - } else { - // Session is disconnected — buffer UpdateToolStatus events so that - // the latest state per tool can be replayed on reconnect. - - if let UiEvent::UpdateToolStatus { + UiEvent::UpdateToolStatus { tool_id, status, message, @@ -885,38 +919,43 @@ impl UserInterface for ProxyUI { styled_output, duration_seconds, images, - } = event - { + } => { + // Record the latest status per tool so snapshots can include + // live tool state that isn't persisted yet. if let Ok(mut buf) = self.tool_status_buffer.lock() { buf.insert( tool_id.clone(), crate::ui::ui_events::ToolResultData { - tool_id, - status, - message, - output, - styled_output, - duration_seconds, - images, + tool_id: tool_id.clone(), + status: *status, + message: message.clone(), + output: output.clone(), + styled_output: styled_output.clone(), + duration_seconds: *duration_seconds, + images: images.clone(), }, ); } } - Ok(()) + _ => {} } + + self.events.publish_ui(&self.session_id, event); + Ok(()) } fn display_fragment(&self, fragment: &DisplayFragment) -> Result<(), UIError> { - // Always buffer fragments + // Record the in-flight fragment for snapshots. Cleared on streaming + // start/stop/rollback, so the buffer is bounded by one response. if let Ok(mut buffer) = self.fragment_buffer.lock() { buffer.push_back(fragment.clone()); - - // Keep buffer size reasonable - while buffer.len() > 1000 { - buffer.pop_front(); - } } + self.events.publish( + Some(self.session_id.clone()), + crate::session::event_stream::EventPayload::Fragment(fragment.clone()), + ); + // Transition from WaitingForResponse to AgentRunning only when the // fragment actually produces something visible in the UI. Some // providers emit empty deltas (e.g. an empty PlainText at the start @@ -940,84 +979,204 @@ impl UserInterface for ProxyUI { }; if has_visible_content { - // Only transition if the agent is still running (not Idle) - let current_state = self - .session_activity_state - .lock() - .map(|s| s.clone()) - .unwrap_or(SessionActivityState::Idle); - if matches!(current_state, SessionActivityState::WaitingForResponse) { - self.update_activity_state(SessionActivityState::AgentRunning); - } + self.publish_activity_change(self.activity.on_visible_output()); } - // Only forward to real UI if session is connected - if self.is_connected() { - self.real_ui.display_fragment(fragment) - } else { - Ok(()) - } + Ok(()) } fn should_streaming_continue(&self) -> bool { - if self.is_connected() { - self.real_ui.should_streaming_continue() - } else { - true // Don't interrupt streaming if session is not connected - } + !self + .stop_requested + .load(std::sync::atomic::Ordering::Relaxed) } fn notify_rate_limit(&self, seconds_remaining: u64) { - // Update session activity state and broadcast - self.update_activity_state(SessionActivityState::RateLimited { seconds_remaining }); - - if self.is_connected() { - self.real_ui.notify_rate_limit(seconds_remaining); - } - // No-op if session not connected + self.publish_activity_change(self.activity.on_rate_limited(seconds_remaining)); } fn clear_rate_limit(&self) { - // Update session activity state back to waiting for response - self.update_activity_state(SessionActivityState::WaitingForResponse); - - if self.is_connected() { - self.real_ui.clear_rate_limit(); - } - // No-op if session not connected - } - - fn as_any(&self) -> &dyn std::any::Any { - self + self.publish_activity_change(self.activity.on_rate_limit_cleared()); } } #[cfg(test)] mod tests { use super::*; - use crate::mocks::MockUI; use std::collections::VecDeque; use std::sync::{Arc, Mutex}; + fn activity_with(state: SessionActivityState) -> SessionActivity { + let activity = SessionActivity::default(); + activity.set(state); + activity + } + + #[test] + fn terminal_states_block_transitions_until_explicit_set() { + for terminal in [ + SessionActivityState::Idle, + SessionActivityState::Errored { + message: "boom".to_string(), + }, + ] { + let activity = activity_with(terminal.clone()); + assert_eq!( + activity.try_transition(SessionActivityState::AgentRunning), + None + ); + assert_eq!(activity.get(), terminal); + + // An explicit set (agent start) leaves the terminal state. + activity.set(SessionActivityState::AgentRunning); + assert_eq!(activity.get(), SessionActivityState::AgentRunning); + } + } + + #[test] + fn try_transition_reports_only_changes() { + let activity = activity_with(SessionActivityState::AgentRunning); + // Same state → no change to broadcast. + assert_eq!( + activity.try_transition(SessionActivityState::AgentRunning), + None + ); + assert_eq!( + activity.try_transition(SessionActivityState::WaitingForResponse), + Some(SessionActivityState::WaitingForResponse) + ); + } + + #[test] + fn streaming_stopped_only_resumes_running_state_on_success() { + // Error → state untouched (the agent task decides the final state). + let activity = activity_with(SessionActivityState::WaitingForResponse); + assert_eq!(activity.on_streaming_stopped(false, true), None); + assert_eq!(activity.get(), SessionActivityState::WaitingForResponse); + + // Cancelled → state untouched. + assert_eq!(activity.on_streaming_stopped(true, false), None); + + // Success from WaitingForResponse → AgentRunning. + assert_eq!( + activity.on_streaming_stopped(false, false), + Some(SessionActivityState::AgentRunning) + ); + + // Success while already AgentRunning → no change. + assert_eq!(activity.on_streaming_stopped(false, false), None); + + // Success from RateLimited → AgentRunning. + let activity = activity_with(SessionActivityState::RateLimited { + seconds_remaining: 5, + }); + assert_eq!( + activity.on_streaming_stopped(false, false), + Some(SessionActivityState::AgentRunning) + ); + + // Success after the agent already completed (Idle) → stays Idle. + let activity = activity_with(SessionActivityState::Idle); + assert_eq!(activity.on_streaming_stopped(false, false), None); + assert_eq!(activity.get(), SessionActivityState::Idle); + } + + #[test] + fn visible_output_moves_waiting_to_running() { + let activity = activity_with(SessionActivityState::WaitingForResponse); + assert_eq!( + activity.on_visible_output(), + Some(SessionActivityState::AgentRunning) + ); + // Only the first visible output transitions. + assert_eq!(activity.on_visible_output(), None); + + // No transition when the agent already finished. + let activity = activity_with(SessionActivityState::Idle); + assert_eq!(activity.on_visible_output(), None); + } + + #[test] + fn rate_limit_round_trip() { + let activity = activity_with(SessionActivityState::WaitingForResponse); + assert_eq!( + activity.on_rate_limited(30), + Some(SessionActivityState::RateLimited { + seconds_remaining: 30 + }) + ); + assert_eq!( + activity.on_rate_limit_cleared(), + Some(SessionActivityState::WaitingForResponse) + ); + + // Rate limit notifications after completion don't revive the session. + let activity = activity_with(SessionActivityState::Idle); + assert_eq!(activity.on_rate_limited(30), None); + assert_eq!(activity.on_rate_limit_cleared(), None); + } + + fn test_publisher(activity: SessionActivity, session_id: &str) -> SessionEventPublisher { + SessionEventPublisher { + events: crate::session::event_stream::EventStream::new(), + fragment_buffer: Arc::new(Mutex::new(VecDeque::new())), + in_flight_node_id: Arc::new(Mutex::new(None)), + tool_status_buffer: Arc::new(Mutex::new(HashMap::new())), + activity, + stop_requested: Arc::new(std::sync::atomic::AtomicBool::new(false)), + session_id: session_id.to_string(), + } + } + #[tokio::test] - async fn test_streaming_stopped_with_error_prevents_agent_running_state() { - let mock_ui = Arc::new(MockUI::default()); - let fragment_buffer = Arc::new(Mutex::new(VecDeque::new())); - let is_session_connected = Arc::new(Mutex::new(true)); - let session_activity_state = Arc::new(Mutex::new(SessionActivityState::WaitingForResponse)); - let session_id = "test-session".to_string(); - - let proxy_ui = ProxyUI::new( - mock_ui.clone(), - fragment_buffer, - Arc::new(Mutex::new(HashMap::new())), - is_session_connected, - session_activity_state.clone(), - session_id, + async fn snapshot_tags_in_flight_message_with_preallocated_node_id() { + let session = crate::persistence::ChatSession::new_empty( + "s1".to_string(), + String::new(), + crate::session::SessionConfig::default(), + None, ); + let instance = SessionInstance::new(session, crate::tools::test_registry()); + let publisher = instance.create_publisher(crate::session::event_stream::EventStream::new()); + + // The agent announces the request with the node id the message will + // be persisted under, then streams fragments. + let _ = publisher + .send_event(UiEvent::StreamingStarted { + request_id: 1, + node_id: 42, + }) + .await; + let _ = publisher.display_fragment(&DisplayFragment::PlainText("hel".to_string())); + let _ = publisher.display_fragment(&DisplayFragment::PlainText("lo".to_string())); + + // A snapshot taken mid-stream carries the partial message tagged + // with the pre-allocated node id, so a frontend rendering it stays + // deduplicatable against the persisted message later. + let snapshot = instance.build_snapshot(None).unwrap(); + let partial = snapshot.messages.last().expect("partial message present"); + assert_eq!(partial.node_id, Some(42)); + assert_eq!(partial.fragments.len(), 2); + + // Once streaming ends the in-flight state is gone. + let _ = publisher + .send_event(UiEvent::StreamingStopped { + id: 1, + cancelled: false, + error: None, + }) + .await; + let snapshot = instance.build_snapshot(None).unwrap(); + assert!(snapshot.messages.is_empty()); + } + + #[tokio::test] + async fn test_streaming_stopped_with_error_prevents_agent_running_state() { + let activity = activity_with(SessionActivityState::WaitingForResponse); + let publisher = test_publisher(activity.clone(), "test-session"); // Simulate StreamingStopped with error - let _ = proxy_ui + let _ = publisher .send_event(UiEvent::StreamingStopped { id: 1, cancelled: false, @@ -1026,23 +1185,13 @@ mod tests { .await; // Verify that the activity state is NOT changed to AgentRunning when there's an error - let final_state = session_activity_state.lock().unwrap().clone(); - assert_eq!(final_state, SessionActivityState::WaitingForResponse); + assert_eq!(activity.get(), SessionActivityState::WaitingForResponse); // Now test without error - should transition to AgentRunning - let session_activity_state2 = - Arc::new(Mutex::new(SessionActivityState::WaitingForResponse)); - - let proxy_ui2 = ProxyUI::new( - mock_ui.clone(), - Arc::new(Mutex::new(VecDeque::new())), - Arc::new(Mutex::new(HashMap::new())), - Arc::new(Mutex::new(true)), - session_activity_state2.clone(), - "test-session-2".to_string(), - ); + let activity2 = activity_with(SessionActivityState::WaitingForResponse); + let publisher2 = test_publisher(activity2.clone(), "test-session-2"); - let _ = proxy_ui2 + let _ = publisher2 .send_event(UiEvent::StreamingStopped { id: 2, cancelled: false, @@ -1051,7 +1200,6 @@ mod tests { .await; // Verify that the activity state IS changed to AgentRunning when there's no error - let final_state2 = session_activity_state2.lock().unwrap().clone(); - assert_eq!(final_state2, SessionActivityState::AgentRunning); + assert_eq!(activity2.get(), SessionActivityState::AgentRunning); } } diff --git a/crates/code_assistant_core/src/session/manager.rs b/crates/code_assistant_core/src/session/manager.rs index 80358446..09b4407a 100644 --- a/crates/code_assistant_core/src/session/manager.rs +++ b/crates/code_assistant_core/src/session/manager.rs @@ -15,7 +15,6 @@ use crate::session::instance::SessionInstance; use crate::session::sleep_inhibitor::SleepInhibitor; use crate::session::{SessionConfig, SessionState}; use crate::ui::ui_events::UiEvent; -use crate::ui::UserInterface; use crate::utils::file_utils; use command_executor::{CommandExecutor, SandboxedCommandExecutor}; use llm::LLMProvider; @@ -95,6 +94,9 @@ pub struct SessionManager { /// The tool registry shared by all sessions this manager runs. tool_registry: Arc, + + /// The core→UI broadcast stream all sessions publish to. + events: crate::session::event_stream::EventStream, } impl SessionManager { @@ -108,6 +110,7 @@ impl SessionManager { session_config_template: SessionConfig, default_model_name: String, tool_registry: Arc, + events: crate::session::event_stream::EventStream, ) -> Self { // Clean up empty sessions from previous runs at startup match persistence.delete_empty_sessions() { @@ -137,9 +140,15 @@ impl SessionManager { force_diff_format, sleep_inhibitor: Arc::new(SleepInhibitor::default()), tool_registry, + events, } } + /// The core→UI broadcast stream this manager's sessions publish to. + pub fn event_stream(&self) -> &crate::session::event_stream::EventStream { + &self.events + } + /// Returns the session config template. pub fn session_config_template(&self) -> &SessionConfig { &self.session_config_template @@ -245,27 +254,18 @@ impl SessionManager { Ok(messages) } - /// Set the UI-active session and return events for UI update. + /// Set the UI-active session and return an owned snapshot for rendering. /// - /// When `edit_until_node_id` is `Some(_)`, the emitted `SetMessages` event - /// is truncated to messages up to and including that node. This lets the + /// When `edit_until_node_id` is `Some(_)`, the snapshot transcript is + /// truncated to messages up to and including that node. This lets the /// UI restore an in-progress message edit (banner + truncated transcript) - /// in a single event when connecting to a session whose draft is in edit - /// mode, rather than loading the full transcript and then truncating it. + /// directly when connecting to a session whose draft is in edit mode, + /// rather than loading the full transcript and then truncating it. pub async fn set_active_session( &mut self, session_id: String, edit_until_node_id: Option, - ) -> Result> { - // Deactivate old session - if let Some(old_id) = &self.active_session_id { - if old_id != &session_id { - if let Some(old_session) = self.active_sessions.get_mut(old_id) { - old_session.set_ui_connected(false); - } - } - } - + ) -> Result { // Check if session exists let session_exists = self.active_sessions.contains_key(&session_id); @@ -278,7 +278,6 @@ impl SessionManager { let mut needs_persist = false; { let session_instance = self.active_sessions.get_mut(&session_id).unwrap(); - session_instance.set_ui_connected(true); // Reload session from persistence to get latest state // This ensures we see any changes made by agents since session was loaded @@ -309,29 +308,21 @@ impl SessionManager { } }; - // Generate UI events for connecting to this session - let mut ui_events = { + // Build the owned snapshot for the frontend + let mut snapshot = { let session_instance = self.active_sessions.get_mut(&session_id).unwrap(); - let events = session_instance.generate_session_connect_events(edit_until_node_id)?; + let snapshot = session_instance.build_snapshot(edit_until_node_id)?; // Mark what the UI now knows about (baseline for future incremental diffs) session_instance.last_ui_synced_path = session_instance.session.active_path.clone(); session_instance.last_ui_synced_tool_count = session_instance.session.tool_executions.len(); - events - }; - - ui_events.push(UiEvent::UpdateCurrentModel { - model_name: model_name_for_event.clone(), - }); - - let sandbox_policy_for_event = { - let session_instance = self.active_sessions.get(&session_id).unwrap(); - session_instance.session.config.sandbox_policy.clone() + snapshot }; - ui_events.push(UiEvent::UpdateSandboxPolicy { - policy: sandbox_policy_for_event, - }); + snapshot.current_model = model_name_for_event; + snapshot.allowed_models = self + .allowed_models_for_session(&session_id) + .unwrap_or_default(); // Check if another process holds the agent lock for this session. // If so, mark it as RunningExternally so the UI disables input. @@ -340,10 +331,8 @@ impl SessionManager { "Session {} has an agent running in another process", session_id ); - ui_events.push(UiEvent::UpdateSessionActivityState { - session_id: session_id.clone(), - activity_state: crate::session::instance::SessionActivityState::RunningExternally, - }); + snapshot.activity_state = + crate::session::instance::SessionActivityState::RunningExternally; } // Set as active @@ -358,7 +347,7 @@ impl SessionManager { self.persistence.save_chat_session(&session_snapshot)?; } - Ok(ui_events) + Ok(snapshot) } /// Incremental refresh of the currently viewed session. @@ -465,12 +454,24 @@ impl SessionManager { return Ok(events); } - // Case 3: Paths diverged → full reload + // Case 3: Paths diverged → full reload of the transcript debug!("Incremental refresh for {session_id}: paths diverged, full reload"); - let ui_events = session_instance.generate_session_connect_events(None)?; + let snapshot = session_instance.build_snapshot(None)?; session_instance.last_ui_synced_path = session_instance.session.active_path.clone(); session_instance.last_ui_synced_tool_count = session_instance.session.tool_executions.len(); - Ok(ui_events) + Ok(vec![ + UiEvent::SetMessages { + messages: snapshot.messages, + session_id: Some(snapshot.session_id), + tool_results: snapshot.tool_results, + }, + UiEvent::UpdatePlan { + plan: snapshot.plan, + }, + UiEvent::UpdateSessionMetadata { + metadata: snapshot.metadata, + }, + ]) } /// Advance the UI-sync baseline to match the current on-disk state. @@ -570,7 +571,6 @@ impl SessionManager { llm_provider: Box, project_manager: Box, command_executor: Box, - ui: Arc, permission_handler: Option>, ) -> Result<()> { // Add the message first @@ -582,7 +582,6 @@ impl SessionManager { llm_provider, project_manager, command_executor, - ui, permission_handler, ) .await @@ -596,7 +595,6 @@ impl SessionManager { llm_provider: Box, project_manager: Box, command_executor: Box, - ui: Arc, permission_handler: Option>, ) -> Result<()> { // Acquire exclusive cross-process agent lock. @@ -614,9 +612,9 @@ impl SessionManager { // Prepare session - need to scope the mutable borrow carefully let ( session_config, - proxy_ui, + publisher, session_state, - activity_state_ref, + activity, pending_message_ref, sandbox_context, ) = { @@ -633,8 +631,12 @@ impl SessionManager { // Clone all needed data to avoid borrowing conflicts let name = session_instance.session.name.clone(); let session_config = session_instance.session.config.clone(); - let proxy_ui = session_instance.create_proxy_ui(ui.clone()); - let activity_state_ref = session_instance.activity_state.clone(); + // A new agent run supersedes any prior stop request and the + // previous run's live tool statuses. + session_instance.begin_agent_run(); + + let publisher = session_instance.create_publisher(self.events.clone()); + let activity = session_instance.activity.clone(); let pending_message_ref = session_instance.pending_message.clone(); let session_state = crate::session::SessionState { @@ -664,9 +666,9 @@ impl SessionManager { ( session_config, - proxy_ui, + publisher, session_state, - activity_state_ref, + activity, pending_message_ref, session_instance.sandbox_context.clone(), ) @@ -676,12 +678,13 @@ impl SessionManager { self.save_session_state(session_state.clone())?; // Broadcast the initial state change - let _ = ui - .send_event(crate::ui::UiEvent::UpdateSessionActivityState { + self.events.publish_ui( + session_id, + crate::ui::UiEvent::UpdateSessionActivityState { session_id: session_id.to_string(), activity_state: crate::session::instance::SessionActivityState::AgentRunning, - }) - .await; + }, + ); // Create agent components let session_manager_ref = Arc::new(Mutex::new(SessionManager::new( @@ -689,6 +692,7 @@ impl SessionManager { self.session_config_template.clone(), self.default_model_name.clone(), self.tool_registry.clone(), + self.events.clone(), ))); let state_storage = Box::new(crate::agent::persistence::SessionStatePersistence::new( @@ -698,7 +702,7 @@ impl SessionManager { let state_storage = Box::new( crate::agent::persistence::MetadataNotifyingPersistence::new( state_storage, - proxy_ui.clone(), + publisher.clone(), ), ); @@ -740,7 +744,7 @@ impl SessionManager { session_config.clone(), sandbox_context_clone, sub_agent_cancellation_registry.clone(), - proxy_ui.clone(), + publisher.clone(), permission_handler.clone(), self.tool_registry.clone(), )); @@ -749,7 +753,7 @@ impl SessionManager { llm_provider, project_manager: sandboxed_project_manager, command_executor: Arc::from(command_executor), - ui: proxy_ui.clone(), + ui: publisher.clone(), state_persistence: state_storage, permission_handler, tool_registry: self.tool_registry.clone(), @@ -765,7 +769,7 @@ impl SessionManager { agent.load_from_session_state(session_state).await?; // Announce the restored plan to the UI - let _ = proxy_ui + let _ = publisher .send_event(UiEvent::UpdatePlan { plan: agent.plan().clone(), }) @@ -777,7 +781,7 @@ impl SessionManager { // lock is held for exactly as long as the agent is running and released // automatically on completion, error, panic, or task abort. let session_id_clone = session_id.to_string(); - let ui_clone = ui.clone(); + let events_clone = self.events.clone(); let sleep_inhibitor = self.sleep_inhibitor.clone(); sleep_inhibitor.agent_started(); @@ -818,17 +822,16 @@ impl SessionManager { "Agent completed successfully for session {}, setting state to Idle", session_id_clone ); - if let Ok(mut state) = activity_state_ref.lock() { - *state = crate::session::instance::SessionActivityState::Idle; - } + activity.set(crate::session::instance::SessionActivityState::Idle); // Broadcast Idle to UI - let _ = ui_clone - .send_event(crate::ui::UiEvent::UpdateSessionActivityState { + events_clone.publish_ui( + &session_id_clone, + crate::ui::UiEvent::UpdateSessionActivityState { session_id: session_id_clone.clone(), activity_state: crate::session::instance::SessionActivityState::Idle, - }) - .await; + }, + ); } Err(e) => { error!("Agent failed for session {}: {}", session_id_clone, e); @@ -845,26 +848,20 @@ impl SessionManager { let errored_state = crate::session::instance::SessionActivityState::Errored { message: error_message.clone(), }; - if let Ok(mut state) = activity_state_ref.lock() { - *state = errored_state.clone(); - } - - // Broadcast Errored state to UI (sidebar update) - let _ = ui_clone - .send_event(crate::ui::UiEvent::UpdateSessionActivityState { + activity.set(errored_state.clone()); + + // Broadcast Errored state (sidebar update). We do NOT + // publish DisplayError here — frontends show the banner + // only when the errored session is the one being viewed + // (via the activity event or the snapshot's connect + // sequence). + events_clone.publish_ui( + &session_id_clone, + crate::ui::UiEvent::UpdateSessionActivityState { session_id: session_id_clone.clone(), activity_state: errored_state, - }) - .await; - - // Note: we do NOT send DisplayError here because ui_clone is the - // raw UI, not the session's ProxyUI — it would show the error - // banner even if the user is looking at a different session. - // Instead, the GPUI event handler for UpdateSessionActivityState - // checks whether the errored session is the currently viewed one - // and shows the banner only then. When the user later switches - // to this session, generate_session_connect_events emits - // DisplayError from the stored Errored state. + }, + ); } } @@ -1495,6 +1492,7 @@ mod tests { template, "test-model".to_string(), crate::tools::test_registry(), + crate::session::event_stream::EventStream::new(), ); (manager, dir) } diff --git a/crates/code_assistant_core/src/session/mod.rs b/crates/code_assistant_core/src/session/mod.rs index 5eddd298..5ad4789d 100644 --- a/crates/code_assistant_core/src/session/mod.rs +++ b/crates/code_assistant_core/src/session/mod.rs @@ -8,13 +8,87 @@ use std::collections::BTreeMap; use std::path::PathBuf; // New session management architecture +pub mod event_stream; pub mod instance; pub mod manager; +pub mod service; pub mod sleep_inhibitor; pub mod watcher; -// Main session manager +// Main session manager, the UI→core command facade on top of it, and the +// core→UI broadcast stream +pub use event_stream::{EventPayload, EventStream, SessionEvent, StreamError, Subscription}; pub use manager::SessionManager; +pub use service::SessionService; + +/// Owned snapshot of everything a frontend needs to render a session. +/// +/// Returned by `SessionService::load_session`. Frontends render it and then +/// apply subsequent events for the session from the broadcast stream; a +/// lagged subscriber recovers by fetching a fresh snapshot. +#[derive(Debug, Clone)] +pub struct SessionSnapshot { + pub session_id: String, + /// Transcript of the active path. When an agent response is currently + /// streaming, the last entry is the in-flight partial assistant message. + pub messages: Vec, + pub tool_results: Vec, + pub plan: PlanState, + pub activity_state: instance::SessionActivityState, + pub metadata: crate::persistence::ChatMetadata, + /// Text summary of a queued (pending) user message, if any. + pub pending_message: Option, + pub current_model: String, + pub allowed_models: Vec, + pub sandbox_policy: SandboxPolicy, +} + +impl SessionSnapshot { + /// Render this snapshot as the canonical connect-event sequence. + /// + /// Frontends that ingest [`crate::ui::UiEvent`]s through a single queue + /// can apply a snapshot by replaying these events in order. + pub fn connect_events(&self) -> Vec { + use crate::ui::UiEvent; + + let mut events = vec![ + UiEvent::SetMessages { + messages: self.messages.clone(), + session_id: Some(self.session_id.clone()), + tool_results: self.tool_results.clone(), + }, + UiEvent::UpdatePlan { + plan: self.plan.clone(), + }, + UiEvent::UpdateSessionActivityState { + session_id: self.session_id.clone(), + activity_state: self.activity_state.clone(), + }, + ]; + // Show the error banner when connecting to an errored session. + if let instance::SessionActivityState::Errored { message } = &self.activity_state { + events.push(UiEvent::DisplayError { + message: message.clone(), + }); + } + events.push(UiEvent::UpdateSessionMetadata { + metadata: self.metadata.clone(), + }); + events.push(UiEvent::UpdatePendingMessage { + message: self.pending_message.clone(), + }); + events.push(UiEvent::UpdateCurrentModel { + model_name: self.current_model.clone(), + }); + events.push(UiEvent::UpdateSandboxPolicy { + policy: self.sandbox_policy.clone(), + }); + events.push(UiEvent::UpdateAllowedModels { + models: self.allowed_models.clone(), + }); + events + } +} /// Static configuration stored with each session. #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/code_assistant_core/src/session/service.rs b/crates/code_assistant_core/src/session/service.rs new file mode 100644 index 00000000..79b01bdd --- /dev/null +++ b/crates/code_assistant_core/src/session/service.rs @@ -0,0 +1,1257 @@ +//! The UI→core command facade. +//! +//! [`SessionService`] is the single entry point for everything a frontend +//! wants the core to *do* (create sessions, send messages, switch models, +//! …). Each operation is a typed async method returning `Result`, so a +//! caller gets *its* answer or *its* error — no correlation over shared +//! channels. +//! +//! Internally the service is an actor: methods enqueue a closure onto a +//! command channel and await a oneshot reply. A single worker future (see +//! [`SessionService::new`]) executes commands strictly in order on the +//! backend's tokio runtime, preserving the serialization of session +//! mutations and keeping the caller's executor (e.g. GPUI) decoupled from +//! tokio. Core→UI notifications keep flowing through [`UiEvent`] and are +//! not part of this API. + +use crate::config::{save_project, DefaultProjectManager}; +use crate::persistence::{ChatMetadata, DraftAttachment, NodeId, SessionModelConfig}; +use crate::session::event_stream::EventStream; +use crate::session::SessionManager; +use crate::skills::{ + discover_session_catalog, load_skill_payload, render_skill_invocation_message, SkillsConfig, +}; +use crate::types::{PlanState, Project}; +use crate::ui::ui_events::{MessageData, ToolResultData}; +use crate::ui::UiEvent; +use crate::utils::content::content_blocks_from; +use anyhow::{anyhow, bail, Context as _, Result}; +use command_executor::CommandExecutor; +use llm::factory::create_llm_client_from_model; +use llm::provider_config::ConfigurationSystem; +use sandbox::SandboxPolicy; +use std::future::Future; +use std::path::PathBuf; +use std::pin::Pin; +use std::sync::Arc; +use tokio::sync::Mutex; +use tracing::{debug, error, info, warn}; + +/// Creates the per-session command executor for agent runs. Injected by the +/// wiring so the core stays frontend-agnostic (the GPUI build supplies an +/// executor that can attach commands to live terminal views). +pub type CommandExecutorFactory = Arc Box + Send + Sync>; + +/// Options for running agents: LLM recording/playback plus the command +/// executor factory. +#[derive(Clone)] +pub struct AgentRuntimeOptions { + pub record_path: Option, + pub playback_path: Option, + pub fast_playback: bool, + /// Builds the command executor for a session id when an agent is started. + pub command_executor_factory: CommandExecutorFactory, +} + +/// A single entry in the input-area skill picker. +#[derive(Debug, Clone)] +pub struct SkillCatalogEntry { + pub name: String, + pub description: String, + /// Scope token to pass back to [`SessionService::invoke_skill`] (the + /// project name, or `:config:` / `:system:`). + pub scope_token: String, + /// Human-readable scope label (`project` / `user` / `system`). + pub scope_label: String, +} + +/// Result of a successful model switch. +#[derive(Debug, Clone)] +pub struct ModelSwitchResult { + /// Optional warning to surface to the user, e.g. when the switch will + /// only affect the next agent iteration. + pub warning: Option, + /// Models that remain valid choices for this session after the switch. + pub allowed_models: Vec, +} + +/// A transcript snapshot: messages plus tool results for one active path. +#[derive(Debug, Clone)] +pub struct TranscriptData { + pub messages: Vec, + pub tool_results: Vec, +} + +/// Everything the UI needs to start editing a past message. +#[derive(Debug, Clone)] +pub struct MessageEditContext { + /// The text content of the message being edited. + pub content: String, + /// Any attachments from the original message. + pub attachments: Vec, + /// The parent node ID where the new branch will be created. + pub branch_parent_id: Option, + /// Transcript truncated to messages before the one being edited. + pub transcript: TranscriptData, +} + +/// Result of switching to a different conversation branch. +#[derive(Debug, Clone)] +pub struct BranchSwitchData { + pub transcript: TranscriptData, + /// Plan state for the new active path. + pub plan: PlanState, +} + +/// Branch/worktree listing for a session's project. +#[derive(Debug, Clone)] +pub struct WorktreeListing { + pub branches: Vec, + pub worktrees: Vec, + pub current_branch: Option, + pub is_git_repo: bool, +} + +/// A git worktree the session was switched to. +#[derive(Debug, Clone)] +pub struct CreatedWorktree { + pub path: PathBuf, + pub branch: String, +} + +/// Outcome of adding a project. +#[derive(Debug, Clone)] +pub enum AddProjectOutcome { + /// Project saved and an initial session created for it. + Added { session_id: String }, + /// The project already exists with the same name and path — no-op. + AlreadyExists, +} + +/// Shared state the worker hands to each command. +#[derive(Clone)] +struct ServiceCtx { + manager: Arc>, + runtime: Arc, + events: EventStream, +} + +impl ServiceCtx { + /// Send a session-scoped notification to the broadcast stream. + fn notify_session(&self, session_id: &str, event: UiEvent) { + self.events.publish_ui(session_id, event); + } +} + +type BoxedCommandFuture = Pin + Send>>; +type Command = Box BoxedCommandFuture + Send>; + +/// Cloneable handle to the session command worker. See module docs. +#[derive(Clone)] +pub struct SessionService { + tx: async_channel::Sender, + events: EventStream, +} + +impl SessionService { + /// Create the service handle and its worker future. The caller must + /// spawn the worker on the tokio runtime that should execute commands + /// (agents started by commands spawn tasks onto that runtime). + /// + /// `events` must be the same stream the [`SessionManager`] publishes to, + /// so that [`SessionService::subscribe`] covers command results and agent + /// streaming alike. + pub fn new( + manager: Arc>, + runtime: Arc, + events: EventStream, + ) -> (Self, impl Future) { + let (tx, rx) = async_channel::unbounded::(); + let ctx = ServiceCtx { + manager, + runtime, + events: events.clone(), + }; + let worker = async move { + debug!("Session service worker started"); + while let Ok(command) = rx.recv().await { + command(ctx.clone()).await; + } + debug!("Session service worker stopped"); + }; + (Self { tx, events }, worker) + } + + /// Subscribe to the core→UI broadcast stream. + pub fn subscribe(&self) -> crate::session::event_stream::Subscription { + self.events.subscribe() + } + + /// Request that the running agent of a session stops at the next + /// opportunity (streaming checkpoint). No-op if no agent is running. + pub async fn request_stop(&self, session_id: String) -> Result<()> { + self.call(move |ctx| async move { + let manager = ctx.manager.lock().await; + let session = manager + .get_session(&session_id) + .ok_or_else(|| anyhow!("Session {session_id} not found"))?; + session.request_stop(); + Ok(()) + }) + .await + } + + /// Enqueue a command and await its typed reply. + async fn call(&self, f: F) -> Result + where + F: FnOnce(ServiceCtx) -> Fut + Send + 'static, + Fut: Future> + Send + 'static, + T: Send + 'static, + { + let (reply_tx, reply_rx) = tokio::sync::oneshot::channel(); + self.tx + .send(Box::new(move |ctx| { + Box::pin(async move { + let _ = reply_tx.send(f(ctx).await); + }) + })) + .await + .map_err(|_| anyhow!("session service is not running"))?; + reply_rx + .await + .map_err(|_| anyhow!("session service dropped the request"))? + } + + // ======================================================================== + // Session management + // ======================================================================== + + /// Create a new session, optionally bound to a project. + pub async fn create_session( + &self, + name: Option, + initial_project: Option, + ) -> Result { + self.call(move |ctx| async move { + let mut manager = ctx.manager.lock().await; + if let Some(project) = initial_project { + // Resolve the project path so the new session gets the correct + // CWD — either from projects.json or from a sibling session + // that already ran in that (temporary) project. + let mut config = manager.session_config_template().clone(); + config.initial_project = project.clone(); + if let Some(path) = manager.resolve_project_path(&project) { + config.init_path = Some(path); + } + manager.create_session_with_config(name, Some(config), None) + } else { + manager.create_session(name) + } + }) + .await + } + + /// Connect a session and return an owned snapshot for rendering. After + /// applying the snapshot, the frontend follows the session on the + /// broadcast stream (see [`SessionService::subscribe`]). + pub async fn load_session( + &self, + session_id: String, + edit_until_node_id: Option, + ) -> Result { + self.call(move |ctx| async move { + let snapshot = { + let mut manager = ctx.manager.lock().await; + manager + .set_active_session(session_id.clone(), edit_until_node_id) + .await? + }; + Ok(snapshot) + }) + .await + } + + pub async fn delete_session(&self, session_id: String) -> Result<()> { + self.call(move |ctx| async move { + let mut manager = ctx.manager.lock().await; + manager.delete_session(&session_id) + }) + .await + } + + pub async fn list_sessions(&self) -> Result> { + self.call(move |ctx| async move { + let manager = ctx.manager.lock().await; + manager.list_all_sessions() + }) + .await + } + + /// Incremental session refresh triggered by the file watcher. Compares + /// the on-disk state with the in-memory state and emits only the delta + /// as [`UiEvent`]s; falls back to a full reload if that fails. + pub async fn refresh_session(&self, session_id: String) -> Result<()> { + let refreshed = self + .call({ + let session_id = session_id.clone(); + move |ctx| async move { + let ui_events = { + let mut manager = ctx.manager.lock().await; + manager.refresh_session_incremental(&session_id)? + }; + for event in ui_events { + ctx.notify_session(&session_id, event); + } + Ok(()) + } + }) + .await; + match refreshed { + Ok(()) => Ok(()), + Err(e) => { + warn!("Incremental refresh failed for {session_id}, falling back: {e}"); + self.load_session(session_id, None).await.map(|_| ()) + } + } + } + + /// Clear the Errored state on a session (user dismissed the error banner). + pub async fn clear_session_error(&self, session_id: String) -> Result<()> { + self.call(move |ctx| async move { + { + let mut manager = ctx.manager.lock().await; + if let Some(session) = manager.get_session_mut(&session_id) { + let current = session.get_activity_state(); + if current.is_terminal() { + session.set_activity_state( + crate::session::instance::SessionActivityState::Idle, + ); + } + } + } + // Broadcast the state change so the sidebar updates + ctx.notify_session( + &session_id.clone(), + UiEvent::UpdateSessionActivityState { + session_id, + activity_state: crate::session::instance::SessionActivityState::Idle, + }, + ); + Ok(()) + }) + .await + } + + /// Clear the conversation context (messages) for a session. The session + /// itself is kept alive; only the message history is wiped. + pub async fn clear_context(&self, session_id: String) -> Result<()> { + self.call(move |ctx| async move { + { + let mut manager = ctx.manager.lock().await; + if let Some(session) = manager.get_session_mut(&session_id) { + let chat = &mut session.session; + chat.message_nodes.clear(); + chat.active_path.clear(); + chat.next_node_id = 1; + chat.messages.clear(); + chat.plan = Default::default(); + } + } + ctx.notify_session(&session_id, UiEvent::ClearMessages); + Ok(()) + }) + .await + } + + /// Compact (summarise) conversation context for a session. + pub async fn compact_context(&self, _session_id: String) -> Result<()> { + bail!("Compact is not yet implemented. Use /clear to reset context.") + } + + /// Update the default model name used for newly created sessions. + pub async fn update_default_model(&self, model_name: String) -> Result<()> { + self.call(move |ctx| async move { + let mut manager = ctx.manager.lock().await; + manager.set_default_model_name(model_name); + Ok(()) + }) + .await + } + + // ======================================================================== + // Agent operations + // ======================================================================== + + /// Add a user message to the session and start the agent for it. + pub async fn send_user_message( + &self, + session_id: String, + message: String, + attachments: Vec, + branch_parent_id: Option, + ) -> Result<()> { + self.call(move |ctx| async move { + send_user_message_impl(&ctx, &session_id, &message, &attachments, branch_parent_id) + .await + }) + .await + } + + /// Queue a user message while the agent is running. Returns the updated + /// pending-message summary. + pub async fn queue_user_message( + &self, + session_id: String, + message: String, + attachments: Vec, + ) -> Result> { + self.call(move |ctx| async move { + let content_blocks = content_blocks_from(&message, &attachments); + let mut manager = ctx.manager.lock().await; + manager.queue_structured_user_message(&session_id, content_blocks)?; + manager.get_pending_message(&session_id) + }) + .await + } + + /// Take the pending message out of the queue for editing. Returns its + /// text, or `None` if nothing was queued. + pub async fn take_pending_message(&self, session_id: String) -> Result> { + self.call(move |ctx| async move { + let mut manager = ctx.manager.lock().await; + manager.request_pending_message_for_edit(&session_id) + }) + .await + } + + /// Resume a session that ended in a state where the agent should run + /// against the existing message history (no new user message is added). + pub async fn resume_session(&self, session_id: String) -> Result<()> { + self.call(move |ctx| async move { resume_session_impl(&ctx, &session_id).await }) + .await + } + + /// Cancel a running sub-agent by its tool id. Returns `true` if a + /// sub-agent was actually cancelled, `false` if it had already finished. + pub async fn cancel_sub_agent(&self, session_id: String, tool_id: String) -> Result { + self.call(move |ctx| async move { + let manager = ctx.manager.lock().await; + manager.cancel_sub_agent(&session_id, &tool_id) + }) + .await + } + + // ======================================================================== + // Skills + // ======================================================================== + + /// List the skills available to a session (across project / user / + /// system scopes), for the input-area skill picker. + pub async fn list_skills(&self, session_id: String) -> Result> { + self.call(move |ctx| async move { + let project_name = { + let manager = ctx.manager.lock().await; + manager + .get_session(&session_id) + .map(|s| s.session.config.initial_project.clone()) + .ok_or_else(|| anyhow!("Session {session_id} not found"))? + }; + let config = SkillsConfig::load(); + let pm = DefaultProjectManager::new(); + Ok(discover_session_catalog(&pm, &project_name, &config) + .into_iter() + .map(|(skill, scope_token)| SkillCatalogEntry { + name: skill.name, + description: skill.description, + scope_label: skill.scope.label().to_string(), + scope_token, + }) + .collect()) + }) + .await + } + + /// User-initiated ("explicit") skill activation: load the skill's body + /// and inject it directly as a synthetic user message, then run the + /// agent. + pub async fn invoke_skill( + &self, + session_id: String, + scope: String, + name: String, + ) -> Result<()> { + self.call(move |ctx| async move { + let config = SkillsConfig::load(); + let pm = DefaultProjectManager::new(); + let payload = load_skill_payload(&pm, &scope, &name, &config) + .with_context(|| format!("Failed to load skill `{name}`"))?; + let message = render_skill_invocation_message(&payload); + + // Record the activation (deduped) so compaction can remind the + // model if the injected body is summarised away. + { + let mut manager = ctx.manager.lock().await; + if let Some(session) = manager.get_session_mut(&session_id) { + if !session.session.active_skills.iter().any(|s| s == &name) { + session.session.active_skills.push(name.clone()); + } + } + if let Err(e) = manager.save_session(&session_id) { + warn!("Failed to persist active_skills for {session_id}: {e}"); + } + } + + send_user_message_impl(&ctx, &session_id, &message, &[], None).await + }) + .await + } + + // ======================================================================== + // Model & sandbox + // ======================================================================== + + pub async fn switch_model( + &self, + session_id: String, + model_name: String, + ) -> Result { + self.call(move |ctx| async move { + let config_system = + ConfigurationSystem::load().context("Failed to load model configuration")?; + if config_system.get_model(&model_name).is_none() { + bail!("Model '{model_name}' not found in configuration."); + } + + let outcome = { + let mut manager = ctx.manager.lock().await; + manager.set_session_model_config( + &session_id, + Some(SessionModelConfig::new(model_name.clone())), + )? + }; + if let Some(warning) = &outcome.warning { + warn!("{}", warning); + } + let allowed_models = { + let manager = ctx.manager.lock().await; + manager + .allowed_models_for_session(&session_id) + .unwrap_or_default() + }; + info!("Switched model for session {session_id} to {model_name}"); + + // Fan out the change so every view of this session updates; the + // warning stays caller-only (it belongs to the interaction). + ctx.notify_session(&session_id, UiEvent::UpdateCurrentModel { model_name }); + ctx.notify_session( + &session_id, + UiEvent::UpdateAllowedModels { + models: allowed_models.clone(), + }, + ); + + Ok(ModelSwitchResult { + warning: outcome.warning, + allowed_models, + }) + }) + .await + } + + pub async fn change_sandbox_policy( + &self, + session_id: String, + policy: SandboxPolicy, + ) -> Result<()> { + self.call(move |ctx| async move { + { + let mut manager = ctx.manager.lock().await; + manager.set_session_sandbox_policy(&session_id, policy.clone())?; + } + // Fan out the change so every view of this session updates. + ctx.notify_session(&session_id, UiEvent::UpdateSandboxPolicy { policy }); + Ok(()) + }) + .await + } + + // ======================================================================== + // Session branching + // ======================================================================== + + /// Prepare editing a past message: returns its content plus the + /// transcript truncated to the messages before it. + pub async fn start_message_edit( + &self, + session_id: String, + node_id: NodeId, + ) -> Result { + self.call(move |ctx| async move { + let manager = ctx.manager.lock().await; + let session_instance = manager + .get_session(&session_id) + .ok_or_else(|| anyhow!("Session {session_id} not found"))?; + let node = session_instance + .session + .message_nodes + .get(&node_id) + .ok_or_else(|| anyhow!("Message node {node_id} not found"))?; + + let content = match &node.message.content { + llm::MessageContent::Text(text) => text.clone(), + llm::MessageContent::Structured(blocks) => blocks + .iter() + .filter_map(|block| match block { + llm::ContentBlock::Text { text, .. } => Some(text.clone()), + _ => None, + }) + .collect::>() + .join("\n"), + }; + let attachments = match &node.message.content { + llm::MessageContent::Structured(blocks) => blocks + .iter() + .filter_map(|block| match block { + llm::ContentBlock::Image { + media_type, data, .. + } => Some(DraftAttachment::Image { + content: data.clone(), + mime_type: media_type.clone(), + width: None, + height: None, + }), + _ => None, + }) + .collect(), + _ => Vec::new(), + }; + + // The branch parent is the parent of the node being edited. + let branch_parent_id = node.parent_id; + let messages = session_instance + .convert_messages_to_ui_data_until( + session_instance.session.config.tool_syntax, + branch_parent_id, + ) + .unwrap_or_default(); + let tool_results = session_instance + .convert_tool_executions_to_ui_data() + .unwrap_or_default(); + + Ok(MessageEditContext { + content, + attachments, + branch_parent_id, + transcript: TranscriptData { + messages, + tool_results, + }, + }) + }) + .await + } + + /// Switch the active path to a sibling branch and return the new + /// transcript. + pub async fn switch_branch( + &self, + session_id: String, + new_node_id: NodeId, + ) -> Result { + self.call(move |ctx| async move { + let mut manager = ctx.manager.lock().await; + let session_instance = manager + .get_session_mut(&session_id) + .ok_or_else(|| anyhow!("Session {session_id} not found"))?; + session_instance + .session + .switch_branch(new_node_id) + .context("Failed to switch branch")?; + + // Persist the updated active_path. Continue on failure — the + // switch worked in memory. + if let Err(e) = manager.save_session(&session_id) { + error!("Failed to save session after branch switch: {}", e); + } + + let session_instance = manager + .get_session(&session_id) + .ok_or_else(|| anyhow!("Session {session_id} not found after save"))?; + let transcript = transcript_data(session_instance)?; + Ok(BranchSwitchData { + transcript, + plan: session_instance.session.plan.clone(), + }) + }) + .await + } + + /// Abort a message edit and return the full transcript of the active + /// path. + pub async fn cancel_message_edit(&self, session_id: String) -> Result { + self.call(move |ctx| async move { + let manager = ctx.manager.lock().await; + let session_instance = manager + .get_session(&session_id) + .ok_or_else(|| anyhow!("Session {session_id} not found"))?; + transcript_data(session_instance) + }) + .await + } + + // ======================================================================== + // Git worktrees + // ======================================================================== + + pub async fn list_branches_and_worktrees(&self, session_id: String) -> Result { + self.call(move |ctx| async move { + let project_root = { + let manager = ctx.manager.lock().await; + session_project_root(&manager, &session_id)? + }; + + if !git::GitRepository::is_repo(&project_root) { + return Ok(WorktreeListing { + branches: Vec::new(), + worktrees: Vec::new(), + current_branch: None, + is_git_repo: false, + }); + } + + let repo = + git::GitRepository::open(&project_root).context("Failed to open git repository")?; + let branches = repo.list_branches().context("Failed to list branches")?; + let current_branch = repo.current_branch(); + let worktrees = match git::worktree::list_worktrees(&repo).await { + Ok(w) => w, + Err(e) => { + // Non-fatal: return branches without worktree info + error!("Failed to list worktrees: {}", e); + Vec::new() + } + }; + Ok(WorktreeListing { + branches, + worktrees, + current_branch, + is_git_repo: true, + }) + }) + .await + } + + pub async fn switch_worktree( + &self, + session_id: String, + worktree_path: Option, + branch: Option, + ) -> Result<()> { + self.call(move |ctx| async move { + let mut manager = ctx.manager.lock().await; + manager.set_session_worktree(&session_id, worktree_path, branch) + }) + .await + } + + /// Create (or reuse) a worktree for `branch_name` and switch the session + /// to it. + pub async fn create_worktree( + &self, + session_id: String, + branch_name: String, + base_branch: Option, + ) -> Result { + self.call(move |ctx| async move { + let project_root = { + let manager = ctx.manager.lock().await; + session_project_root(&manager, &session_id)? + }; + let repo = + git::GitRepository::open(&project_root).context("Failed to open git repository")?; + + // Reuse an existing worktree for this branch if there is one. + let existing = match git::worktree::find_worktree_for_branch(&repo, &branch_name).await + { + Ok(existing) => existing, + Err(e) => { + debug!("Could not check existing worktrees: {}", e); + None + } + }; + let path = match existing { + Some(worktree) => { + info!( + "Reusing existing worktree for branch '{}' at {:?}", + branch_name, worktree.path + ); + worktree.path + } + None => { + let worktree_path = + git::worktree::suggest_worktree_path(repo.workdir(), &branch_name); + git::worktree::create_worktree( + &repo.git, + repo.workdir(), + &worktree_path, + &branch_name, + base_branch.as_deref(), + ) + .await + .context("Failed to create worktree")? + } + }; + + let mut manager = ctx.manager.lock().await; + manager + .set_session_worktree(&session_id, Some(path.clone()), Some(branch_name.clone())) + .context("Worktree created but failed to update session")?; + Ok(CreatedWorktree { + path, + branch: branch_name, + }) + }) + .await + } + + // ======================================================================== + // Projects + // ======================================================================== + + /// Add a new project to projects.json and create an initial session for + /// it. + pub async fn add_project(&self, name: String, path: PathBuf) -> Result { + self.call(move |ctx| async move { + // No-op if this project already exists with the same name & path. + if let Ok(existing_projects) = crate::config::load_projects() { + if let Some(existing) = existing_projects.get(&name) { + let existing_canonical = existing.path.canonicalize().ok(); + let new_canonical = path.canonicalize().ok(); + let paths_match = match (&existing_canonical, &new_canonical) { + (Some(a), Some(b)) => a == b, + _ => existing.path == path, + }; + if paths_match { + info!( + "Project '{}' already exists with the same path — no-op", + name + ); + return Ok(AddProjectOutcome::AlreadyExists); + } + } + } + + save_project( + &name, + &Project { + path: path.clone(), + format_on_save: None, + }, + ) + .context("Failed to save project")?; + + let mut manager = ctx.manager.lock().await; + let mut config = manager.session_config_template().clone(); + config.initial_project = name.clone(); + config.init_path = Some(path); + let session_id = manager + .create_session_with_config(None, Some(config), None) + .context("Project saved but failed to create session")?; + info!("Created initial session {session_id} for project '{name}'"); + Ok(AddProjectOutcome::Added { session_id }) + }) + .await + } + + /// Persist a temporary project to projects.json so it becomes a + /// first-class project. + pub async fn persist_project(&self, project_name: String) -> Result<()> { + self.call(move |ctx| async move { + let path = { + let manager = ctx.manager.lock().await; + manager.resolve_project_path(&project_name) + } + .ok_or_else(|| { + anyhow!("Cannot persist project '{project_name}': unable to determine its path") + })?; + + save_project( + &project_name, + &Project { + path, + format_on_save: None, + }, + ) + .context("Failed to persist project")?; + info!("Project '{project_name}' persisted to projects.json"); + Ok(()) + }) + .await + } +} + +fn transcript_data( + session_instance: &crate::session::instance::SessionInstance, +) -> Result { + let messages = session_instance + .convert_messages_to_ui_data(session_instance.session.config.tool_syntax) + .context("Failed to convert messages")?; + let tool_results = session_instance + .convert_tool_executions_to_ui_data() + .context("Failed to convert tool results")?; + Ok(TranscriptData { + messages, + tool_results, + }) +} + +/// Resolve the project root path for a session (init_path, not +/// worktree_path). +fn session_project_root(manager: &SessionManager, session_id: &str) -> Result { + let session = manager + .get_session(session_id) + .ok_or_else(|| anyhow!("Session {session_id} not found"))?; + session + .session + .config + .init_path + .clone() + .ok_or_else(|| anyhow!("Session has no project path configured")) +} + +async fn send_user_message_impl( + ctx: &ServiceCtx, + session_id: &str, + message: &str, + attachments: &[DraftAttachment], + branch_parent_id: Option, +) -> Result<()> { + debug!( + "User message for session {}: {} (with {} attachments, branch_parent: {:?})", + session_id, + message, + attachments.len(), + branch_parent_id + ); + + let content_blocks = content_blocks_from(message, attachments); + + // First, add the user message to the session and get the new node_id. + let (new_node_id, branch_info_updates) = { + let mut manager = ctx.manager.lock().await; + let node_id = manager + .add_user_message(session_id, content_blocks, branch_parent_id) + .context("Failed to add user message")?; + // If we created a branch, get branch info updates for all siblings. + let updates = if branch_parent_id.is_some() { + manager.get_sibling_branch_infos(session_id, node_id) + } else { + Vec::new() + }; + (node_id, updates) + }; + + // Now display the user message with the correct node_id. + ctx.notify_session( + session_id, + UiEvent::DisplayUserInput { + content: message.to_string(), + attachments: attachments.to_vec(), + node_id: Some(new_node_id), + }, + ); + + // Send branch info updates for all siblings (so they show the branch + // switcher). + for (sibling_node_id, branch_info) in branch_info_updates { + ctx.notify_session( + session_id, + UiEvent::UpdateBranchInfo { + node_id: sibling_node_id, + branch_info, + }, + ); + } + + start_agent_impl(ctx, session_id).await +} + +async fn resume_session_impl(ctx: &ServiceCtx, session_id: &str) -> Result<()> { + debug!("ResumeSession requested for {}", session_id); + + // Refuse to resume if an agent is already running for this session, or + // if it's locked by another instance. Clear a prior Errored state so the + // UI doesn't keep the error banner. + { + let mut manager = ctx.manager.lock().await; + if manager.is_agent_locked_externally(session_id) { + bail!("Cannot resume: another instance is running this session."); + } + if let Some(instance) = manager.get_session(session_id) { + if !instance.get_activity_state().is_terminal() { + bail!("Cannot resume: agent is already running for this session."); + } + } + if let Some(session) = manager.get_session_mut(session_id) { + if matches!( + session.get_activity_state(), + crate::session::instance::SessionActivityState::Errored { .. } + ) { + session.set_activity_state(crate::session::instance::SessionActivityState::Idle); + } + } + } + + start_agent_impl(ctx, session_id).await +} + +/// Start the agent loop for a session against its current message history. +async fn start_agent_impl(ctx: &ServiceCtx, session_id: &str) -> Result<()> { + let session_config = { + let manager = ctx.manager.lock().await; + manager.get_session_model_config(session_id).unwrap_or(None) + }; + let Some(session_config) = session_config else { + bail!( + "Session has no model configuration. Please ensure all sessions are created with a model." + ); + }; + + let llm_client = create_llm_client_from_model( + &session_config.model_name, + ctx.runtime.playback_path.clone(), + ctx.runtime.fast_playback, + ctx.runtime.record_path.clone(), + ) + .await + .context("Failed to create LLM client")?; + + let project_manager = Box::new(DefaultProjectManager::new()); + let command_executor = (ctx.runtime.command_executor_factory)(session_id); + + let mut manager = ctx.manager.lock().await; + manager + .set_session_model_config(session_id, Some(session_config)) + .context("Failed to persist model config")?; + manager + .start_agent_for_session( + session_id, + llm_client, + project_manager, + command_executor, + None, + ) + .await + .context("Failed to start agent")?; + debug!("Agent started for session {}", session_id); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::persistence::FileSessionPersistence; + use crate::session::SessionConfig; + + fn test_service(root: &std::path::Path) -> SessionService { + let events = EventStream::new(); + let persistence = FileSessionPersistence::new_with_root_dir(root.to_path_buf()); + let manager = Arc::new(Mutex::new(SessionManager::new( + persistence, + SessionConfig::default(), + "test-model".to_string(), + crate::tools::test_registry(), + events.clone(), + ))); + let runtime = Arc::new(AgentRuntimeOptions { + record_path: None, + playback_path: None, + fast_playback: false, + command_executor_factory: Arc::new(|_| { + Box::new(crate::mocks::create_command_executor_mock()) + }), + }); + let (service, worker) = SessionService::new(manager, runtime, events); + tokio::spawn(worker); + service + } + + #[tokio::test] + async fn create_list_delete_session_roundtrip() { + let tmp = tempfile::tempdir().unwrap(); + let service = test_service(tmp.path()); + + let id = service + .create_session(Some("first".to_string()), None) + .await + .unwrap(); + + let sessions = service.list_sessions().await.unwrap(); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].id, id); + assert_eq!(sessions[0].name, "first"); + + service.delete_session(id).await.unwrap(); + assert!(service.list_sessions().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn load_session_returns_snapshot() { + let tmp = tempfile::tempdir().unwrap(); + let service = test_service(tmp.path()); + + let id = service.create_session(None, None).await.unwrap(); + let snapshot = service.load_session(id.clone(), None).await.unwrap(); + + assert_eq!(snapshot.session_id, id); + assert_eq!(snapshot.current_model, "test-model"); + assert!(snapshot.messages.is_empty()); + // The canonical connect sequence renders the snapshot state. + assert!(snapshot + .connect_events() + .iter() + .any(|e| matches!(e, UiEvent::UpdateCurrentModel { model_name } if model_name == "test-model"))); + } + + #[tokio::test] + async fn load_unknown_session_fails() { + let tmp = tempfile::tempdir().unwrap(); + let service = test_service(tmp.path()); + let err = service + .load_session("does-not-exist".to_string(), None) + .await + .unwrap_err(); + assert!(err.to_string().contains("does-not-exist")); + } + + #[tokio::test] + async fn queue_and_take_pending_message() { + let tmp = tempfile::tempdir().unwrap(); + let service = test_service(tmp.path()); + let id = service.create_session(None, None).await.unwrap(); + + let pending = service + .queue_user_message(id.clone(), "hello".to_string(), Vec::new()) + .await + .unwrap(); + assert_eq!(pending.as_deref(), Some("hello")); + + // Queueing again appends. + let pending = service + .queue_user_message(id.clone(), "world".to_string(), Vec::new()) + .await + .unwrap(); + assert_eq!(pending.as_deref(), Some("hello\nworld")); + + // Taking it for edit clears the queue. + let taken = service.take_pending_message(id.clone()).await.unwrap(); + assert_eq!(taken.as_deref(), Some("hello\nworld")); + assert_eq!(service.take_pending_message(id).await.unwrap(), None); + } + + #[tokio::test] + async fn command_notifications_reach_stream_subscribers() { + let tmp = tempfile::tempdir().unwrap(); + let service = test_service(tmp.path()); + let id = service.create_session(None, None).await.unwrap(); + + let mut subscription = service.subscribe(); + service.clear_context(id.clone()).await.unwrap(); + + // The ClearMessages notification arrives session-tagged on the + // broadcast stream. + loop { + let event = subscription.recv().await.unwrap(); + if matches!( + event.payload, + crate::session::event_stream::EventPayload::Ui(UiEvent::ClearMessages) + ) { + assert_eq!(event.session_id.as_deref(), Some(id.as_str())); + break; + } + } + } + + #[tokio::test] + async fn request_stop_sets_session_flag() { + let tmp = tempfile::tempdir().unwrap(); + let service = test_service(tmp.path()); + let id = service.create_session(None, None).await.unwrap(); + + // Unknown session errors. + assert!(service.request_stop("nope".to_string()).await.is_err()); + + service.request_stop(id).await.unwrap(); + } + + #[tokio::test] + async fn compact_context_reports_unimplemented() { + let tmp = tempfile::tempdir().unwrap(); + let service = test_service(tmp.path()); + let err = service + .compact_context("any".to_string()) + .await + .unwrap_err(); + assert!(err.to_string().contains("not yet implemented")); + } + + #[tokio::test] + async fn start_message_edit_unknown_session_fails() { + let tmp = tempfile::tempdir().unwrap(); + let service = test_service(tmp.path()); + let err = service + .start_message_edit("nope".to_string(), 1) + .await + .unwrap_err(); + assert!(err.to_string().contains("not found")); + } + + #[tokio::test] + async fn service_reports_stopped_worker() { + let tmp = tempfile::tempdir().unwrap(); + let events = EventStream::new(); + let persistence = FileSessionPersistence::new_with_root_dir(tmp.path().to_path_buf()); + let manager = Arc::new(Mutex::new(SessionManager::new( + persistence, + SessionConfig::default(), + "test-model".to_string(), + crate::tools::test_registry(), + events.clone(), + ))); + let runtime = Arc::new(AgentRuntimeOptions { + record_path: None, + playback_path: None, + fast_playback: false, + command_executor_factory: Arc::new(|_| { + Box::new(crate::mocks::create_command_executor_mock()) + }), + }); + let (service, worker) = SessionService::new(manager, runtime, events); + drop(worker); // never spawned + + let err = service.list_sessions().await.unwrap_err(); + assert!(err.to_string().contains("not running")); + } + + #[tokio::test] + async fn commands_execute_in_submission_order() { + let tmp = tempfile::tempdir().unwrap(); + let service = test_service(tmp.path()); + + // Fire several creates concurrently and make sure each gets its own + // typed reply (no cross-talk between concurrent callers). + let mut handles = Vec::new(); + for i in 0..5 { + let service = service.clone(); + handles.push(tokio::spawn(async move { + service.create_session(Some(format!("s{i}")), None).await + })); + } + let mut ids = std::collections::HashSet::new(); + for handle in handles { + ids.insert(handle.await.unwrap().unwrap()); + } + assert_eq!(ids.len(), 5); + assert_eq!(service.list_sessions().await.unwrap().len(), 5); + } +} diff --git a/crates/code_assistant_core/src/skills/bundled.rs b/crates/code_assistant_core/src/skills/bundled.rs index 5511af24..d41c6318 100644 --- a/crates/code_assistant_core/src/skills/bundled.rs +++ b/crates/code_assistant_core/src/skills/bundled.rs @@ -26,9 +26,7 @@ const SALT: &str = "v1"; /// Honors [`SkillsConfig::bundled_skills_enabled`]: when bundled skills are /// disabled, any previously-extracted tree is removed instead. pub fn install_system_skills() -> Result<()> { - let system_root = crate::config_dir::config_dir() - .join("skills") - .join(".system"); + let system_root = crate::config::system_skills_root(&crate::config_dir::config_dir()); let config = crate::skills::SkillsConfig::load(); install_system_skills_into(&system_root, config.bundled_skills_enabled) } diff --git a/crates/code_assistant_core/src/skills/loader.rs b/crates/code_assistant_core/src/skills/loader.rs index 767ab60e..b83e37cf 100644 --- a/crates/code_assistant_core/src/skills/loader.rs +++ b/crates/code_assistant_core/src/skills/loader.rs @@ -2,13 +2,16 @@ //! //! Skills are found under three roots, in precedence order: //! - **Project**: `/.agents/skills//SKILL.md` -//! - **User**: `/skills//SKILL.md` +//! - **User**: `~/.agents/skills//SKILL.md` (shared across harnesses) //! - **System**: `/skills/.system//SKILL.md` (bundled) //! //! On a name collision the higher-precedence scope wins (project > user > //! system). -use crate::config::{explorer_for_scope, ProjectManager, SCOPE_CONFIG, SCOPE_SYSTEM}; +use crate::config::{ + explorer_for_scope, system_skills_root, user_skills_root, ProjectManager, SCOPE_CONFIG, + SCOPE_SYSTEM, +}; use crate::skills::config::SkillsConfig; use crate::skills::manifest::parse_skill_content; @@ -112,7 +115,7 @@ pub struct ScopeSkills { /// Resolve a scope token to the skills it contains and the sandbox root that /// `read_files` uses for that scope: /// - a project name → the project's `.agents/skills`, sandbox root = project root -/// - [`SCOPE_CONFIG`] (`:config:`) → `/skills` +/// - [`SCOPE_CONFIG`] (`:config:`) → `~/.agents/skills` /// - [`SCOPE_SYSTEM`] (`:system:`) → `/skills/.system` pub fn discover_scope_skills( project_manager: &dyn ProjectManager, @@ -193,11 +196,8 @@ pub fn discover_session_catalog( pub fn discover_config_and_system_skills() -> Vec { let config_dir = crate::config_dir::config_dir(); discover_across_roots(&[ - (config_dir.join("skills"), SkillScope::User), - ( - config_dir.join("skills").join(".system"), - SkillScope::System, - ), + (user_skills_root(), SkillScope::User), + (system_skills_root(&config_dir), SkillScope::System), ]) } @@ -210,11 +210,8 @@ pub fn discover_all_skills_filtered(project_root: &Path, config: &SkillsConfig) project_root.join(".agents").join("skills"), SkillScope::Project, ), - (config_dir.join("skills"), SkillScope::User), - ( - config_dir.join("skills").join(".system"), - SkillScope::System, - ), + (user_skills_root(), SkillScope::User), + (system_skills_root(&config_dir), SkillScope::System), ]); config.filter_skills(discovered) } diff --git a/crates/code_assistant_core/src/ui/mod.rs b/crates/code_assistant_core/src/ui/mod.rs index cde44628..5f24f68f 100644 --- a/crates/code_assistant_core/src/ui/mod.rs +++ b/crates/code_assistant_core/src/ui/mod.rs @@ -24,9 +24,6 @@ pub trait UserInterface: Send + Sync { /// Clear rate limit notification fn clear_rate_limit(&self); - - /// Downcast to Any for accessing concrete type methods - fn as_any(&self) -> &dyn std::any::Any; } /// Implements the agent core's UI boundary on top of a [`UserInterface`]: diff --git a/crates/code_assistant_core/src/ui/streaming/test_utils.rs b/crates/code_assistant_core/src/ui/streaming/test_utils.rs index 3e13f6d1..650d4a7a 100644 --- a/crates/code_assistant_core/src/ui/streaming/test_utils.rs +++ b/crates/code_assistant_core/src/ui/streaming/test_utils.rs @@ -119,10 +119,6 @@ impl UserInterface for TestUI { fn clear_rate_limit(&self) { // Test implementation does nothing with rate limit clearing } - - fn as_any(&self) -> &dyn std::any::Any { - self - } } /// The stream processors consume the agent core's UI boundary; fragments take diff --git a/crates/code_assistant_core/src/ui/ui_events.rs b/crates/code_assistant_core/src/ui/ui_events.rs index 6dfe87bb..11a6ddda 100644 --- a/crates/code_assistant_core/src/ui/ui_events.rs +++ b/crates/code_assistant_core/src/ui/ui_events.rs @@ -199,16 +199,7 @@ pub enum UiEvent { /// Update the chat list display UpdateChatList { sessions: Vec }, /// Clear all messages - #[allow(dead_code)] ClearMessages, - /// Send user message with optional attachments to active session (triggers agent) - SendUserMessage { - message: String, - session_id: String, - attachments: Vec, - /// If set, creates a new branch from this parent node instead of appending to active path - branch_parent_id: Option, - }, /// Update metadata for a single session without refreshing the entire list UpdateSessionMetadata { metadata: ChatMetadata }, /// Update activity state for a single session @@ -216,15 +207,6 @@ pub enum UiEvent { session_id: String, activity_state: SessionActivityState, }, - /// Queue a user message with optional attachments while agent is running - QueueUserMessage { - message: String, - session_id: String, - attachments: Vec, - }, - /// Request to edit pending message (move back to input) - #[allow(dead_code)] - RequestPendingMessageEdit { session_id: String }, /// Update pending message display UpdatePendingMessage { message: Option }, /// Display an error message to the user @@ -248,9 +230,6 @@ pub enum UiEvent { /// Update the current sandbox selection in the UI UpdateSandboxPolicy { policy: SandboxPolicy }, - /// Cancel a running sub-agent by its tool id - CancelSubAgent { tool_id: String }, - /// Schedule a debounced save of the per-session UI state file. /// Sent after any mutation to the UI state (tool collapse toggle, plan /// toggle, etc.). The handler cancels any pending save timer and starts @@ -258,21 +237,9 @@ pub enum UiEvent { PersistUiState, // === Session Branching Events === - /// Request to start editing a message (creates a branch point) - /// UI should load the message content into the input area - StartMessageEdit { - session_id: String, - /// The node ID of the message being edited - node_id: NodeId, - }, - /// Switch to a different branch at a branch point - SwitchBranch { - session_id: String, - /// The node ID to switch to (a sibling of the current node at a branch point) - new_node_id: NodeId, - }, - /// Response: Message content loaded for editing - /// Sent in response to StartMessageEdit + /// The transcript was truncated for a message edit and the message + /// content should be loaded into the input area. Emitted by the GPUI + /// edit flow after `SessionService::start_message_edit`. MessageEditReady { /// The text content of the message content: String, @@ -285,16 +252,6 @@ pub enum UiEvent { /// Tool results for the truncated path tool_results: Vec, }, - /// Response: Branch switch completed, new messages to display - BranchSwitched { - session_id: String, - /// Full message list for the new active path - messages: Vec, - /// Tool results for the new path - tool_results: Vec, - /// Updated plan for the new path - plan: PlanState, - }, /// Update the branch info for a specific message node /// Used when a new branch is created to update the UI for the parent message UpdateBranchInfo { diff --git a/crates/llm/src/anthropic.rs b/crates/llm/src/anthropic.rs index ad48c1b1..50e2dc61 100644 --- a/crates/llm/src/anthropic.rs +++ b/crates/llm/src/anthropic.rs @@ -1371,12 +1371,6 @@ impl LLMProvider for AnthropicClient { let mut anthropic_request = serde_json::json!({ "model": self.model, "max_tokens": max_tokens, - "temperature": if matches!(thinking_mode, ThinkingMode::None) { - 0.7 - } else { - // Anthropic requires this to be 1.0 if you enable "thinking" - 1.0 - }, "system": system, "stream": streaming_callback.is_some(), "messages": messages_json, diff --git a/crates/ui_acp/src/agent.rs b/crates/ui_acp/src/agent.rs index f61b2bc1..a69fb542 100644 --- a/crates/ui_acp/src/agent.rs +++ b/crates/ui_acp/src/agent.rs @@ -784,8 +784,6 @@ impl AgentState { uis.insert(arguments.session_id.0.to_string(), acp_ui.clone()); } - let ui: Arc = acp_ui.clone(); - // Detect a `/skill` slash command before converting (clients send the // advertised command name as prompt text). let skill_command = slash_command_token(&arguments.prompt); @@ -895,20 +893,6 @@ impl AgentState { Box::new(DefaultCommandExecutor) }; - // Mark session as connected so ProxyUI forwards to our UI - { - let mut manager = self.session_manager.lock().await; - if let Some(session) = manager.get_session_mut(&arguments.session_id.0) { - session.set_ui_connected(true); - tracing::debug!("ACP: Marked session as UI-connected"); - } else { - let error_msg = "Session not found when trying to mark as connected"; - tracing::error!("{}", error_msg); - self.remove_active_ui(&arguments.session_id.0).await; - return Err(to_acp_error(&anyhow::anyhow!(error_msg))); - } - } - let permission_handler: Option> = Some(Arc::new( AcpPermissionMediator::new(arguments.session_id.clone(), cx.clone(), acp_ui.clone()), ) @@ -930,7 +914,6 @@ impl AgentState { llm_client, project_manager, command_executor, - ui.clone(), permission_handler, ) .await @@ -939,7 +922,6 @@ impl AgentState { { let error_msg = format!("Failed to start agent: {e}"); tracing::error!("{}", error_msg); - self.set_disconnected(&arguments.session_id.0).await; self.remove_active_ui(&arguments.session_id.0).await; return Err(to_acp_error(&e.context(error_msg))); } @@ -987,9 +969,13 @@ impl AgentState { if is_idle { tracing::info!("ACP: Agent is idle, exiting wait loop"); + // Give the stream router a moment to deliver the final + // events of the turn (tool statuses, errors) before the UI + // is deregistered from `active_uis`. + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + if let Some(Err(e)) = task_result { tracing::error!("ACP: Agent task failed: {}", e); - self.set_disconnected(&arguments.session_id.0).await; self.remove_active_ui(&arguments.session_id.0).await; return Err(to_acp_error(&e)); } @@ -997,9 +983,8 @@ impl AgentState { break; } - if !ui.should_streaming_continue() { + if !acp_ui.should_streaming_continue() { tracing::info!("ACP: Streaming cancelled"); - self.set_disconnected(&arguments.session_id.0).await; self.remove_active_ui(&arguments.session_id.0).await; return Ok(acp::PromptResponse::new(acp::StopReason::Cancelled)); } @@ -1010,7 +995,6 @@ impl AgentState { arguments.session_id.0 ); - self.set_disconnected(&arguments.session_id.0).await; self.remove_active_ui(&arguments.session_id.0).await; if let Some(message) = acp_ui.take_last_error() { @@ -1038,13 +1022,6 @@ impl AgentState { let mut uis = self.active_uis.lock().await; uis.remove(session_id); } - - async fn set_disconnected(&self, session_id: &str) { - let mut manager = self.session_manager.lock().await; - if let Some(session) = manager.get_session_mut(session_id) { - session.set_ui_connected(false); - } - } } #[cfg(test)] diff --git a/crates/ui_acp/src/app.rs b/crates/ui_acp/src/app.rs index 2bb9dc21..1cdcf861 100644 --- a/crates/ui_acp/src/app.rs +++ b/crates/ui_acp/src/app.rs @@ -71,11 +71,13 @@ pub async fn run(verbose: bool, config: AgentRunConfig) -> Result<()> { let persistence = FileSessionPersistence::new(); let persistence_for_watcher = FileSessionPersistence::new(); let tool_registry = code_assistant_core::tools::default_registry(); + let events = code_assistant_core::session::event_stream::EventStream::new(); let session_manager = Arc::new(Mutex::new(SessionManager::new( persistence, session_config_template.clone(), model_name.clone(), tool_registry.clone(), + events.clone(), ))); // Channel for session notifications: `ACPUserUI` instances push into the @@ -96,6 +98,43 @@ pub async fn run(verbose: bool, config: AgentRunConfig) -> Result<()> { connected_session_id.clone(), )); + // Route the core→UI broadcast stream to the per-session ACP UIs: each + // running prompt registers its ACPUserUI in `active_uis`; events for + // sessions without an active prompt are dropped (ACP has no view to + // update outside a prompt turn). + { + let active_uis = state.active_uis(); + let mut subscription = events.subscribe(); + tokio::spawn(async move { + use code_assistant_core::session::{EventPayload, StreamError}; + loop { + match subscription.recv().await { + Ok(event) => { + let Some(session_id) = &event.session_id else { + continue; + }; + let ui = active_uis.lock().await.get(session_id).cloned(); + let Some(ui) = ui else { + continue; + }; + match event.payload { + EventPayload::Fragment(fragment) => { + let _ = ui.display_fragment(&fragment); + } + EventPayload::Ui(ui_event) => { + let _ = ui.send_event(ui_event).await; + } + } + } + Err(StreamError::Lagged { missed }) => { + warn!("ACP event stream lagged ({missed} events missed)"); + } + Err(StreamError::Closed) => break, + } + } + }); + } + // Start the filesystem watcher for cross-instance awareness. let (watcher_event_tx, watcher_event_rx) = async_channel::bounded::(64); let _session_watcher = match SessionWatcher::start( diff --git a/crates/ui_acp/src/ui.rs b/crates/ui_acp/src/ui.rs index 6934af4a..0a7e94f5 100644 --- a/crates/ui_acp/src/ui.rs +++ b/crates/ui_acp/src/ui.rs @@ -895,20 +895,13 @@ impl UserInterface for ACPUserUI { | UiEvent::RefreshChatList | UiEvent::UpdateChatList { .. } | UiEvent::ClearMessages - | UiEvent::SendUserMessage { .. } | UiEvent::UpdateSessionActivityState { .. } - | UiEvent::QueueUserMessage { .. } - | UiEvent::RequestPendingMessageEdit { .. } | UiEvent::UpdatePendingMessage { .. } | UiEvent::ClearError | UiEvent::UpdateCurrentModel { .. } | UiEvent::UpdateSandboxPolicy { .. } - | UiEvent::CancelSubAgent { .. } | UiEvent::HiddenToolCompleted - | UiEvent::StartMessageEdit { .. } - | UiEvent::SwitchBranch { .. } | UiEvent::MessageEditReady { .. } - | UiEvent::BranchSwitched { .. } | UiEvent::UpdateBranchInfo { .. } | UiEvent::RollbackStreaming { .. } | UiEvent::ShowTransientStatus { .. } @@ -1105,10 +1098,6 @@ impl UserInterface for ACPUserUI { fn clear_rate_limit(&self) { // No action needed } - - fn as_any(&self) -> &dyn std::any::Any { - self - } } #[cfg(test)] diff --git a/crates/ui_gpui/src/app/backend.rs b/crates/ui_gpui/src/app/backend.rs deleted file mode 100644 index 893b1f04..00000000 --- a/crates/ui_gpui/src/app/backend.rs +++ /dev/null @@ -1,358 +0,0 @@ -//! Backend response handling. -//! -//! Contains `Gpui::handle_backend_response` — processes responses from the -//! background session management thread (session created/loaded/deleted, etc.). - -use code_assistant_core::backend::BackendResponse; -use gpui::AsyncApp; -use tracing::{debug, info, warn}; - -use super::super::*; - -impl Gpui { - pub(crate) fn handle_backend_response(&self, response: BackendResponse, cx: &mut AsyncApp) { - match response { - BackendResponse::SessionCreated { session_id } => { - debug!("Received BackendResponse::SessionCreated"); - *self.current_session_id.lock().unwrap() = Some(session_id.clone()); - // Refresh the session list - if let Some(sender) = self.backend_event_sender.lock().unwrap().as_ref() { - let _ = sender.try_send(BackendEvent::ListSessions); - // Load the newly created session to connect it to the UI - let _ = sender.try_send(BackendEvent::LoadSession { - session_id: session_id.clone(), - edit_until_node_id: None, - }); - // Refresh the skill catalog for the `/skill` picker. - let _ = sender.try_send(BackendEvent::ListSkills { session_id }); - } - } - - BackendResponse::SessionDeleted { session_id } => { - debug!("Received BackendResponse::SessionDeleted"); - // Clean up collapse-state overrides for the deleted session - blocks::ToolCollapseState::remove_session(&session_id); - - // If the deleted session was the currently active one, disconnect - // from it so the messages view shows the "no session" hint. - // (This may already have been done eagerly in the delete-request - // handler in root.rs, in which case the check is a no-op.) - let is_current = - self.current_session_id.lock().unwrap().as_deref() == Some(session_id.as_str()); - if is_current { - debug!("Deleted session was the active session — clearing UI state"); - self.clear_current_session_state(); - - // Tell MessagesView there is no session - self.update_messages_view(cx, |view, cx| { - view.set_current_session_id(None); - view.messages_reset(0, cx); - cx.notify(); - }); - } - - // Refresh the session list - if let Some(sender) = self.backend_event_sender.lock().unwrap().as_ref() { - let _ = sender.try_send(BackendEvent::ListSessions); - } - cx.refresh(); - } - BackendResponse::SessionsListed { sessions } => { - debug!("Received BackendResponse::SessionsListed"); - *self.chat_sessions.lock().unwrap() = sessions.clone(); - self.push_event(UiEvent::UpdateChatList { sessions }); - } - BackendResponse::SkillsListed { session_id, skills } => { - debug!( - "Received BackendResponse::SkillsListed for {} ({} skills)", - session_id, - skills.len() - ); - // Cache for the `/skill` input-area completion + invocation. - *self.skills.lock().unwrap() = skills; - } - BackendResponse::Error { message } => { - warn!("Backend error: {}", message); - // Display the error to the user - self.push_event(UiEvent::DisplayError { message }); - } - BackendResponse::PendingMessageForEdit { - session_id, - message: _, - } => { - debug!( - "Received BackendResponse::PendingMessageForEdit for session {}", - session_id - ); - // TODO: Move pending message to text input field for editing - // For now, clear the pending message display - self.push_event(UiEvent::UpdatePendingMessage { message: None }); - } - BackendResponse::PendingMessageUpdated { - session_id, - message, - } => { - debug!( - "Received BackendResponse::PendingMessageUpdated for session {}", - session_id - ); - // Only update pending message display if this is for the current session - if let Some(current_session_id) = self.current_session_id.lock().unwrap().as_ref() { - if current_session_id == &session_id { - self.push_event(UiEvent::UpdatePendingMessage { message }); - } - } - } - BackendResponse::ModelSwitched { - session_id, - model_name, - warning, - allowed_models, - } => { - let current_session_id = self.current_session_id.lock().unwrap().clone(); - if current_session_id.as_deref() == Some(session_id.as_str()) { - debug!( - "Received BackendResponse::ModelSwitched for active session {}: {}", - session_id, model_name - ); - self.push_event(UiEvent::UpdateCurrentModel { - model_name: model_name.clone(), - }); - self.push_event(UiEvent::UpdateAllowedModels { - models: allowed_models, - }); - if let Some(message) = warning { - self.push_event(UiEvent::ShowTransientStatus { message }); - } - } else { - debug!( - "Ignoring BackendResponse::ModelSwitched for session {} (current: {:?})", - session_id, current_session_id - ); - } - } - - BackendResponse::SandboxPolicyChanged { session_id, policy } => { - let current_session_id = self.current_session_id.lock().unwrap().clone(); - if current_session_id.as_deref() == Some(session_id.as_str()) { - debug!( - "Received BackendResponse::SandboxPolicyChanged for active session {}", - session_id - ); - self.push_event(UiEvent::UpdateSandboxPolicy { policy }); - } else { - debug!( - "Ignoring BackendResponse::SandboxPolicyChanged for session {} (current: {:?})", - session_id, current_session_id - ); - } - } - - BackendResponse::SubAgentCancelled { - session_id, - tool_id, - } => { - debug!( - "Received BackendResponse::SubAgentCancelled for tool {} in session {}", - tool_id, session_id - ); - // The sub-agent will update its own UI state via the normal tool output mechanism - // No additional UI update needed here - } - - // Session branching responses - BackendResponse::MessageEditReady { - session_id, - content, - attachments, - branch_parent_id, - messages, - tool_results, - } => { - debug!( - "Received BackendResponse::MessageEditReady for session {} with {} chars, {} attachments, {} messages", - session_id, - content.len(), - attachments.len(), - messages.len() - ); - - // Forward to UI as event - self.process_ui_event_async( - UiEvent::MessageEditReady { - content: content.clone(), - attachments: attachments.clone(), - branch_parent_id, - messages: messages.clone(), - tool_results: tool_results.clone(), - }, - cx, - ); - } - - BackendResponse::BranchSwitched { - session_id, - messages, - tool_results, - plan, - } => { - debug!( - "Received BackendResponse::BranchSwitched for session {} with {} messages", - session_id, - messages.len() - ); - // Forward to UI as event - self.process_ui_event_async( - UiEvent::BranchSwitched { - session_id: session_id.clone(), - messages: messages.clone(), - tool_results: tool_results.clone(), - plan: plan.clone(), - }, - cx, - ); - } - BackendResponse::MessageEditCancelled { - session_id, - messages, - tool_results, - } => { - debug!( - "Received BackendResponse::MessageEditCancelled for session {} with {} messages", - session_id, - messages.len() - ); - - // Forward to UI as event - reuse SetMessages to restore the view - self.process_ui_event_async( - UiEvent::SetMessages { - messages: messages.clone(), - session_id: Some(session_id.clone()), - tool_results: tool_results.clone(), - }, - cx, - ); - } - - // Git worktree responses — forwarded to the WorktreeSelector component - BackendResponse::BranchesAndWorktreesListed { - session_id, - worktrees, - current_branch: _, - is_git_repo, - .. - } => { - let current_session_id = self.current_session_id.lock().unwrap().clone(); - if current_session_id.as_deref() == Some(session_id.as_str()) { - debug!( - "Received BranchesAndWorktreesListed for active session {}: {} worktrees, is_git_repo={}", - session_id, worktrees.len(), is_git_repo - ); - // Preserve the current selection (set by WorktreeSwitched / WorktreeCreated) - let existing_path = self - .current_worktree_data - .lock() - .unwrap() - .as_ref() - .and_then(|d| d.current_worktree_path.clone()); - self.push_event(UiEvent::UpdateWorktreeData { - worktrees, - current_worktree_path: existing_path, - is_git_repo, - }); - } else { - debug!( - "Ignoring BranchesAndWorktreesListed for session {} (current: {:?})", - session_id, current_session_id - ); - } - } - BackendResponse::WorktreeSwitched { - session_id, - worktree_path, - branch, - } => { - let current_session_id = self.current_session_id.lock().unwrap().clone(); - if current_session_id.as_deref() == Some(session_id.as_str()) { - info!( - "Worktree switched for active session {}: path={:?}, branch={:?}", - session_id, worktree_path, branch - ); - // Update the stored worktree data with the new selection, preserving the list - let current_data = self.current_worktree_data.lock().unwrap().clone(); - let (worktrees, is_git_repo) = current_data - .map(|d| (d.worktrees, d.is_git_repo)) - .unwrap_or_default(); - self.push_event(UiEvent::UpdateWorktreeData { - worktrees, - current_worktree_path: worktree_path, - is_git_repo, - }); - // Also refresh the full list since worktrees may have changed - if let Some(sender) = self.backend_event_sender.lock().unwrap().as_ref() { - let _ = - sender.try_send(BackendEvent::ListBranchesAndWorktrees { session_id }); - } - } - } - BackendResponse::WorktreeCreated { - session_id, - worktree_path, - branch, - } => { - let current_session_id = self.current_session_id.lock().unwrap().clone(); - if current_session_id.as_deref() == Some(session_id.as_str()) { - info!( - "Worktree created for active session {}: {:?} (branch: {})", - session_id, worktree_path, branch - ); - // Update selection to the newly created worktree, preserving the existing list - let current_data = self.current_worktree_data.lock().unwrap().clone(); - let (worktrees, is_git_repo) = current_data - .map(|d| (d.worktrees, d.is_git_repo)) - .unwrap_or_default(); - self.push_event(UiEvent::UpdateWorktreeData { - worktrees, - current_worktree_path: Some(worktree_path), - is_git_repo, - }); - // Refresh the full list to include the new worktree - if let Some(sender) = self.backend_event_sender.lock().unwrap().as_ref() { - let _ = - sender.try_send(BackendEvent::ListBranchesAndWorktrees { session_id }); - } - } - } - BackendResponse::ProjectAdded { - project_name, - session_id, - } => { - info!( - "Project '{}' added, initial session: {}", - project_name, session_id - ); - *self.current_session_id.lock().unwrap() = Some(session_id.clone()); - // Refresh the session list and load the new session - if let Some(sender) = self.backend_event_sender.lock().unwrap().as_ref() { - let _ = sender.try_send(BackendEvent::ListSessions); - let _ = sender.try_send(BackendEvent::LoadSession { - session_id: session_id.clone(), - edit_until_node_id: None, - }); - let _ = sender.try_send(BackendEvent::ListSkills { session_id }); - } - } - BackendResponse::ProjectPersisted { project_name } => { - info!("Project '{}' persisted to projects.json", project_name); - // Update the set of persisted projects so the sidebar can - // remove the "pin" icon for this project. - self.persisted_projects.lock().unwrap().insert(project_name); - // Trigger a re-render so the sidebar picks up the change. - cx.refresh(); - } - BackendResponse::ProjectAlreadyExists { project_name } => { - info!("Project '{}' already exists — nothing to do", project_name); - } - } - } -} diff --git a/crates/ui_gpui/src/app/commands.rs b/crates/ui_gpui/src/app/commands.rs new file mode 100644 index 00000000..52cdc279 --- /dev/null +++ b/crates/ui_gpui/src/app/commands.rs @@ -0,0 +1,559 @@ +//! UI→core commands. +//! +//! Every user action that asks the core to *do* something goes through the +//! typed [`SessionService`] methods here. Each command runs as a detached +//! task on the GPUI background executor; its typed result (or error) is +//! applied to the shared state and pushed into the UI event queue, which is +//! the single ingestion point for display updates on the foreground thread. + +use code_assistant_core::persistence::{DraftAttachment, NodeId}; +use code_assistant_core::session::service::{AddProjectOutcome, SessionService}; +use std::future::Future; +use std::path::PathBuf; +use tracing::{debug, info, warn}; + +use super::super::*; + +impl Gpui { + /// Install the session service handle. Called by the wiring before + /// `run_app`. + pub fn set_session_service(&self, service: SessionService) { + *self.session_service.lock().unwrap() = Some(service); + } + + pub(crate) fn session_service(&self) -> Option { + let service = self.session_service.lock().unwrap().clone(); + if service.is_none() { + warn!("Session service not available"); + } + service + } + + /// Run a command future as a detached task on the GPUI background + /// executor. Commands only touch thread-safe state and the UI event + /// queue, never entities. + pub(crate) fn dispatch(&self, fut: impl Future + Send + 'static) { + let executor = self.background_executor.lock().unwrap().clone(); + if let Some(executor) = executor { + executor.spawn(fut).detach(); + } else { + warn!("Cannot dispatch session command: app not running yet"); + } + } + + fn display_error(&self, message: String) { + self.push_event(UiEvent::DisplayError { message }); + } + + fn is_current_session(&self, session_id: &str) -> bool { + self.current_session_id.lock().unwrap().as_deref() == Some(session_id) + } + + // ======================================================================== + // Sessions + // ======================================================================== + + /// Fetch the session list and publish it to the sidebar. + pub(crate) fn cmd_refresh_chat_list(&self) { + let Some(service) = self.session_service() else { + return; + }; + let gpui = self.clone(); + self.dispatch(async move { + match service.list_sessions().await { + Ok(sessions) => { + *gpui.chat_sessions.lock().unwrap() = sessions.clone(); + gpui.push_event(UiEvent::UpdateChatList { sessions }); + } + Err(e) => gpui.display_error(format!("Failed to list sessions: {e:#}")), + } + }); + } + + /// Create a session and connect it to the UI. + pub(crate) fn cmd_create_session(&self, name: Option, initial_project: Option) { + let Some(service) = self.session_service() else { + return; + }; + let gpui = self.clone(); + self.dispatch(async move { + match service.create_session(name, initial_project).await { + Ok(session_id) => { + *gpui.current_session_id.lock().unwrap() = Some(session_id.clone()); + gpui.cmd_refresh_chat_list(); + gpui.cmd_load_session(session_id, None); + } + Err(e) => gpui.display_error(format!("Failed to create session: {e:#}")), + } + }); + } + + /// Connect a session to the UI: apply the returned snapshot and refresh + /// the skill catalog for the `/skill` picker. + pub(crate) fn cmd_load_session(&self, session_id: String, edit_until_node_id: Option) { + let Some(service) = self.session_service() else { + return; + }; + let gpui = self.clone(); + self.dispatch(async move { + match service + .load_session(session_id.clone(), edit_until_node_id) + .await + { + Ok(snapshot) => { + gpui.apply_snapshot(&snapshot); + gpui.refresh_skills(session_id); + } + Err(e) => gpui.display_error(format!("Failed to load session: {e:#}")), + } + }); + } + + /// Ask the running agent of a session to stop. The local stop-request + /// set drives the cancel button state; the actual stop happens core-side + /// at the agent's next streaming checkpoint. + pub(crate) fn cmd_request_stop(&self, session_id: String) { + self.session_stop_requests + .lock() + .unwrap() + .insert(session_id.clone()); + let Some(service) = self.session_service() else { + return; + }; + self.dispatch(async move { + if let Err(e) = service.request_stop(session_id).await { + debug!("Failed to request agent stop: {e:#}"); + } + }); + } + + /// Delete a session. The caller is responsible for disconnecting the + /// messages view first if the session is currently shown (see the + /// sidebar delete handler in `main_screen`). + pub(crate) fn cmd_delete_session(&self, session_id: String) { + let Some(service) = self.session_service() else { + return; + }; + let gpui = self.clone(); + self.dispatch(async move { + match service.delete_session(session_id.clone()).await { + Ok(()) => { + // Clean up collapse-state overrides for the deleted session + blocks::ToolCollapseState::remove_session(&session_id); + if gpui.is_current_session(&session_id) { + gpui.clear_current_session_state(); + } + gpui.cmd_refresh_chat_list(); + } + Err(e) => gpui.display_error(format!("Failed to delete session: {e:#}")), + } + }); + } + + /// Incremental refresh after another process changed the session file. + pub(crate) fn cmd_refresh_session(&self, session_id: String) { + let Some(service) = self.session_service() else { + return; + }; + let gpui = self.clone(); + self.dispatch(async move { + if let Err(e) = service.refresh_session(session_id).await { + debug!("Session refresh failed: {e:#}"); + let _ = gpui; + } + }); + } + + /// Reset an Errored session back to Idle (user dismissed the banner). + pub(crate) fn cmd_clear_session_error(&self, session_id: String) { + let Some(service) = self.session_service() else { + return; + }; + self.dispatch(async move { + if let Err(e) = service.clear_session_error(session_id).await { + debug!("Failed to clear session error: {e:#}"); + } + }); + } + + // ======================================================================== + // Agent + // ======================================================================== + + pub(crate) fn cmd_send_user_message( + &self, + session_id: String, + message: String, + attachments: Vec, + branch_parent_id: Option, + ) { + let Some(service) = self.session_service() else { + return; + }; + // Clear any existing error when the user sends a new message + *self.current_error.lock().unwrap() = None; + let gpui = self.clone(); + self.dispatch(async move { + if let Err(e) = service + .send_user_message(session_id, message, attachments, branch_parent_id) + .await + { + gpui.display_error(format!("Failed to send message: {e:#}")); + } + }); + } + + pub(crate) fn cmd_queue_user_message( + &self, + session_id: String, + message: String, + attachments: Vec, + ) { + let Some(service) = self.session_service() else { + return; + }; + let gpui = self.clone(); + self.dispatch(async move { + match service + .queue_user_message(session_id.clone(), message, attachments) + .await + { + Ok(pending) => { + if gpui.is_current_session(&session_id) { + gpui.push_event(UiEvent::UpdatePendingMessage { message: pending }); + } + } + Err(e) => gpui.display_error(format!("Failed to queue message: {e:#}")), + } + }); + } + + /// Resume an errored/killed session against its existing history. + pub(crate) fn cmd_resume_session(&self, session_id: String) { + let Some(service) = self.session_service() else { + return; + }; + let gpui = self.clone(); + self.dispatch(async move { + if let Err(e) = service.resume_session(session_id).await { + gpui.display_error(format!("{e:#}")); + } + }); + } + + /// Cancel a running sub-agent of the current session by tool id. + pub(crate) fn cmd_cancel_sub_agent(&self, tool_id: String) { + let Some(session_id) = self.current_session_id.lock().unwrap().clone() else { + warn!("CancelSubAgent requested but no active session"); + return; + }; + let Some(service) = self.session_service() else { + return; + }; + self.dispatch(async move { + match service.cancel_sub_agent(session_id, tool_id).await { + // The sub-agent updates its own tool card via the normal + // tool-output mechanism; nothing else to apply here. + Ok(_cancelled) => {} + Err(e) => debug!("Failed to cancel sub-agent: {e:#}"), + } + }); + } + + // ======================================================================== + // Skills + // ======================================================================== + + /// Refresh the cached skill catalog used by the `/skill` input-area + /// completion. + pub fn refresh_skills(&self, session_id: String) { + let Some(service) = self.session_service() else { + return; + }; + let gpui = self.clone(); + self.dispatch(async move { + match service.list_skills(session_id).await { + Ok(skills) => gpui.set_skills(skills), + Err(e) => debug!("Failed to list skills: {e:#}"), + } + }); + } + + pub(crate) fn cmd_invoke_skill(&self, session_id: String, scope: String, name: String) { + let Some(service) = self.session_service() else { + return; + }; + let gpui = self.clone(); + self.dispatch(async move { + if let Err(e) = service.invoke_skill(session_id, scope, name).await { + gpui.display_error(format!("{e:#}")); + } + }); + } + + // ======================================================================== + // Model & sandbox + // ======================================================================== + + pub(crate) fn cmd_switch_model(&self, session_id: String, model_name: String) { + let Some(service) = self.session_service() else { + return; + }; + let gpui = self.clone(); + self.dispatch(async move { + // Model/allowed-models updates arrive via the broadcast stream; + // only the caller shows the interaction-scoped warning. + match service.switch_model(session_id.clone(), model_name).await { + Ok(result) => { + if let Some(message) = result.warning { + if gpui.is_current_session(&session_id) { + gpui.push_event(UiEvent::ShowTransientStatus { message }); + } + } + } + Err(e) => gpui.display_error(format!("{e:#}")), + } + }); + } + + pub(crate) fn cmd_update_default_model(&self, model_name: String) { + let Some(service) = self.session_service() else { + return; + }; + self.dispatch(async move { + if let Err(e) = service.update_default_model(model_name).await { + debug!("Failed to update default model: {e:#}"); + } + }); + } + + pub(crate) fn cmd_change_sandbox_policy( + &self, + session_id: String, + policy: sandbox::SandboxPolicy, + ) { + let Some(service) = self.session_service() else { + return; + }; + let gpui = self.clone(); + self.dispatch(async move { + // The UpdateSandboxPolicy notification arrives via the stream. + if let Err(e) = service.change_sandbox_policy(session_id, policy).await { + gpui.display_error(format!("{e:#}")); + } + }); + } + + // ======================================================================== + // Branching + // ======================================================================== + + /// Prepare editing a past message: truncates the transcript and loads + /// the message content into the input area (via `MessageEditReady`). + pub(crate) fn cmd_start_message_edit(&self, session_id: String, node_id: NodeId) { + let Some(service) = self.session_service() else { + return; + }; + let gpui = self.clone(); + self.dispatch(async move { + match service.start_message_edit(session_id, node_id).await { + Ok(edit) => gpui.push_event(UiEvent::MessageEditReady { + content: edit.content, + attachments: edit.attachments, + branch_parent_id: edit.branch_parent_id, + messages: edit.transcript.messages, + tool_results: edit.transcript.tool_results, + }), + Err(e) => gpui.display_error(format!("Failed to start message edit: {e:#}")), + } + }); + } + + pub(crate) fn cmd_switch_branch(&self, session_id: String, new_node_id: NodeId) { + let Some(service) = self.session_service() else { + return; + }; + let gpui = self.clone(); + self.dispatch(async move { + match service.switch_branch(session_id.clone(), new_node_id).await { + Ok(data) => { + gpui.push_event(UiEvent::SetMessages { + messages: data.transcript.messages, + session_id: Some(session_id), + tool_results: data.transcript.tool_results, + }); + gpui.push_event(UiEvent::UpdatePlan { plan: data.plan }); + } + Err(e) => gpui.display_error(format!("Failed to switch branch: {e:#}")), + } + }); + } + + /// Abort a message edit: restore the full transcript of the active path. + pub(crate) fn cmd_cancel_message_edit(&self, session_id: String) { + let Some(service) = self.session_service() else { + return; + }; + let gpui = self.clone(); + self.dispatch(async move { + match service.cancel_message_edit(session_id.clone()).await { + Ok(transcript) => gpui.push_event(UiEvent::SetMessages { + messages: transcript.messages, + session_id: Some(session_id), + tool_results: transcript.tool_results, + }), + Err(e) => gpui.display_error(format!("Failed to cancel message edit: {e:#}")), + } + }); + } + + // ======================================================================== + // Worktrees + // ======================================================================== + + pub(crate) fn cmd_list_branches_and_worktrees(&self, session_id: String) { + let Some(service) = self.session_service() else { + return; + }; + let gpui = self.clone(); + self.dispatch(async move { + match service + .list_branches_and_worktrees(session_id.clone()) + .await + { + Ok(listing) => { + if gpui.is_current_session(&session_id) { + // Preserve the current selection (set by the switch / + // create commands) + let existing_path = gpui + .current_worktree_data + .lock() + .unwrap() + .as_ref() + .and_then(|d| d.current_worktree_path.clone()); + gpui.push_event(UiEvent::UpdateWorktreeData { + worktrees: listing.worktrees, + current_worktree_path: existing_path, + is_git_repo: listing.is_git_repo, + }); + } + } + Err(e) => debug!("Failed to list branches/worktrees: {e:#}"), + } + }); + } + + pub(crate) fn cmd_switch_worktree( + &self, + session_id: String, + worktree_path: Option, + branch: Option, + ) { + let Some(service) = self.session_service() else { + return; + }; + let gpui = self.clone(); + self.dispatch(async move { + match service + .switch_worktree(session_id.clone(), worktree_path.clone(), branch) + .await + { + Ok(()) => { + if gpui.is_current_session(&session_id) { + info!( + "Worktree switched for active session {session_id}: {worktree_path:?}" + ); + gpui.update_worktree_selection(worktree_path); + // Refresh the full list since worktrees may have changed + gpui.cmd_list_branches_and_worktrees(session_id); + } + } + Err(e) => gpui.display_error(format!("Failed to switch worktree: {e:#}")), + } + }); + } + + pub(crate) fn cmd_create_worktree(&self, session_id: String, branch_name: String) { + let Some(service) = self.session_service() else { + return; + }; + let gpui = self.clone(); + self.dispatch(async move { + match service + .create_worktree(session_id.clone(), branch_name, None) + .await + { + Ok(created) => { + if gpui.is_current_session(&session_id) { + info!( + "Worktree created for active session {session_id}: {:?} (branch: {})", + created.path, created.branch + ); + gpui.update_worktree_selection(Some(created.path)); + gpui.cmd_list_branches_and_worktrees(session_id); + } + } + Err(e) => gpui.display_error(format!("Failed to create worktree: {e:#}")), + } + }); + } + + /// Update the stored worktree selection, preserving the cached listing. + fn update_worktree_selection(&self, worktree_path: Option) { + let current_data = self.current_worktree_data.lock().unwrap().clone(); + let (worktrees, is_git_repo) = current_data + .map(|d| (d.worktrees, d.is_git_repo)) + .unwrap_or_default(); + self.push_event(UiEvent::UpdateWorktreeData { + worktrees, + current_worktree_path: worktree_path, + is_git_repo, + }); + } + + // ======================================================================== + // Projects + // ======================================================================== + + /// Add a project and connect its initial session. + pub(crate) fn cmd_add_project(&self, name: String, path: PathBuf) { + let Some(service) = self.session_service() else { + return; + }; + let gpui = self.clone(); + self.dispatch(async move { + match service.add_project(name.clone(), path).await { + Ok(AddProjectOutcome::Added { session_id }) => { + info!("Project '{name}' added, initial session: {session_id}"); + *gpui.current_session_id.lock().unwrap() = Some(session_id.clone()); + gpui.cmd_refresh_chat_list(); + gpui.cmd_load_session(session_id, None); + } + Ok(AddProjectOutcome::AlreadyExists) => { + info!("Project '{name}' already exists — nothing to do"); + } + Err(e) => gpui.display_error(format!("Failed to add project: {e:#}")), + } + }); + } + + /// Persist a temporary project to projects.json. + pub(crate) fn cmd_persist_project(&self, project_name: String) { + let Some(service) = self.session_service() else { + return; + }; + let gpui = self.clone(); + self.dispatch(async move { + match service.persist_project(project_name.clone()).await { + Ok(()) => { + // Update the set of persisted projects so the sidebar can + // remove the "pin" icon for this project; the chat-list + // refresh triggers the re-render. + gpui.persisted_projects.lock().unwrap().insert(project_name); + gpui.cmd_refresh_chat_list(); + } + Err(e) => gpui.display_error(format!("Failed to persist project: {e:#}")), + } + }); + } +} diff --git a/crates/ui_gpui/src/app/user_interface_impl.rs b/crates/ui_gpui/src/app/event_bridge.rs similarity index 56% rename from crates/ui_gpui/src/app/user_interface_impl.rs rename to crates/ui_gpui/src/app/event_bridge.rs index b777f4c3..9eaf207a 100644 --- a/crates/ui_gpui/src/app/user_interface_impl.rs +++ b/crates/ui_gpui/src/app/event_bridge.rs @@ -1,17 +1,92 @@ -//! `UserInterface` trait implementation for `Gpui`. +//! Subscription to the core→UI broadcast stream. //! -//! This is the bridge between the agent system and the GUI — it receives -//! events and display fragments from the agent loop and forwards them into -//! the GPUI event queue for processing on the UI thread. +//! The bridge is GPUI's single ingestion point for everything the core +//! publishes. It filters by the currently viewed session — sidebar-relevant +//! events (activity, metadata, chat list) pass regardless — and forwards +//! into the internal UI event queue, where the existing processing on the +//! foreground thread takes over. On lag it resyncs by reloading a fresh +//! snapshot of the viewed session. -use async_trait::async_trait; -use code_assistant_core::ui::{DisplayFragment, UIError, UiEvent, UserInterface}; +use code_assistant_core::session::{EventPayload, SessionEvent, SessionSnapshot, StreamError}; +use code_assistant_core::ui::UiEvent; +use tracing::{debug, warn}; use super::super::*; -#[async_trait] -impl UserInterface for Gpui { - async fn send_event(&self, event: UiEvent) -> Result<(), UIError> { +impl Gpui { + /// Subscribe to the broadcast stream and forward events until it closes. + /// Called once from `run_app`. + pub(crate) fn spawn_event_bridge(&self) { + let Some(service) = self.session_service() else { + warn!("No session service — event bridge not started"); + return; + }; + let gpui = self.clone(); + self.dispatch(async move { + let mut subscription = service.subscribe(); + debug!("Event bridge started"); + loop { + match subscription.recv().await { + Ok(event) => gpui.handle_stream_event(event).await, + Err(StreamError::Lagged { missed }) => { + warn!("Event stream lagged ({missed} events missed) — resyncing"); + if let Some(session_id) = gpui.get_current_session_id() { + gpui.cmd_load_session(session_id, None); + } + } + Err(StreamError::Closed) => { + debug!("Event stream closed — bridge stopped"); + break; + } + } + } + }); + } + + /// Apply one stream event: decide whether it concerns this view, then + /// feed it into the internal UI event queue. + async fn handle_stream_event(&self, event: SessionEvent) { + let current = self.get_current_session_id(); + let is_current_session = event.session_id == current; + + match event.payload { + EventPayload::Fragment(fragment) => { + // Streaming fragments only matter for the viewed session; + // background sessions are resynced via snapshot on switch. + if is_current_session { + let _ = self.handle_fragment(&fragment); + } + } + EventPayload::Ui(ui_event) => { + let forward = match &ui_event { + // Sidebar state: relevant for every session, always. + UiEvent::UpdateSessionActivityState { .. } + | UiEvent::UpdateSessionMetadata { .. } + | UiEvent::UpdateChatList { .. } + | UiEvent::RefreshChatList + | UiEvent::ConfigChanged => true, + // Everything else: app-scoped events pass, session-scoped + // events only for the viewed session. + _ => event.session_id.is_none() || is_current_session, + }; + if forward { + let _ = self.handle_app_event(ui_event).await; + } + } + } + } + + /// Apply an owned session snapshot by replaying the canonical connect + /// sequence through the internal event queue. + pub fn apply_snapshot(&self, snapshot: &SessionSnapshot) { + for event in snapshot.connect_events() { + self.push_event(event); + } + } + + /// Ingest an application event: track side state, then enqueue it for + /// processing on the foreground thread. + pub(crate) async fn handle_app_event(&self, event: UiEvent) { // Handle special events that need state management match &event { UiEvent::StreamingStarted { request_id, .. } => { @@ -38,10 +113,16 @@ impl UserInterface for Gpui { // Forward all events to the event processing self.push_event(event); - Ok(()) } - fn display_fragment(&self, fragment: &DisplayFragment) -> Result<(), UIError> { + /// Translate a streaming display fragment of the viewed session into + /// the internal event vocabulary. + pub(crate) fn handle_fragment( + &self, + fragment: &code_assistant_core::ui::DisplayFragment, + ) -> Result<(), code_assistant_core::ui::UIError> { + use code_assistant_core::ui::{DisplayFragment, UIError}; + match fragment { DisplayFragment::PlainText(text) => { self.push_event(UiEvent::AppendToTextBlock { @@ -76,7 +157,10 @@ impl UserInterface for Gpui { tool_id, } => { if tool_id.is_empty() { - error!("StreamingProcessor provided empty tool ID for parameter '{}' - this is a bug!", name); + tracing::error!( + "StreamingProcessor provided empty tool ID for parameter '{}' - this is a bug!", + name + ); return Err(UIError::IOError(std::io::Error::new( std::io::ErrorKind::InvalidData, format!("Empty tool ID for parameter '{name}'"), @@ -150,30 +234,4 @@ impl UserInterface for Gpui { Ok(()) } - - fn should_streaming_continue(&self) -> bool { - // Check if the current session has requested a stop - if let Some(current_session_id) = self.current_session_id.lock().unwrap().as_ref() { - let stop_requests = self.session_stop_requests.lock().unwrap(); - if stop_requests.contains(current_session_id) { - return false; - } - } - - // Default: continue streaming - true - } - - fn notify_rate_limit(&self, _seconds_remaining: u64) { - // This is not handled here, but in the ProxyUI of each SessionInstance. - // We receive separate events for SessionActivityState - } - - fn clear_rate_limit(&self) { - // See notify_rate_limit() - } - - fn as_any(&self) -> &dyn std::any::Any { - self - } } diff --git a/crates/ui_gpui/src/app/event_loop.rs b/crates/ui_gpui/src/app/event_loop.rs index dde30182..34919640 100644 --- a/crates/ui_gpui/src/app/event_loop.rs +++ b/crates/ui_gpui/src/app/event_loop.rs @@ -516,12 +516,7 @@ impl Gpui { } UiEvent::RefreshChatList => { debug!("UI: RefreshChatList event received"); - if let Some(sender) = self.backend_event_sender.lock().unwrap().as_ref() { - debug!("UI: Sending ListSessions to backend"); - let _ = sender.try_send(BackendEvent::ListSessions); - } else { - warn!("UI: No backend event sender available for RefreshChatList"); - } + self.cmd_refresh_chat_list(); } UiEvent::UpdateChatList { sessions } => { debug!( @@ -546,33 +541,6 @@ impl Gpui { self.notify_messages_reset(cx); } - UiEvent::SendUserMessage { - message, - session_id, - attachments, - branch_parent_id, - } => { - debug!( - "UI: SendUserMessage event for session {}: {} (with {} attachments, branch_parent: {:?})", - session_id, - message, - attachments.len(), - branch_parent_id - ); - // Clear any existing error when user sends a new message - *self.current_error.lock().unwrap() = None; - - if let Some(sender) = self.backend_event_sender.lock().unwrap().as_ref() { - let _ = sender.try_send(BackendEvent::SendUserMessage { - session_id, - message, - attachments, - branch_parent_id, - }); - } else { - warn!("UI: No backend event sender available"); - } - } UiEvent::UpdateSessionMetadata { metadata } => { debug!( "UI: UpdateSessionMetadata event for session {}", @@ -670,34 +638,6 @@ impl Gpui { } } } - UiEvent::QueueUserMessage { - message, - session_id, - attachments, - } => { - debug!( - "UI: QueueUserMessage event for session {}: {} (with {} attachments)", - session_id, - message, - attachments.len() - ); - if let Some(sender) = self.backend_event_sender.lock().unwrap().as_ref() { - let _ = sender.try_send(BackendEvent::QueueUserMessage { - session_id, - message, - attachments, - }); - } - } - UiEvent::RequestPendingMessageEdit { session_id } => { - debug!( - "UI: RequestPendingMessageEdit event for session {}", - session_id - ); - if let Some(sender) = self.backend_event_sender.lock().unwrap().as_ref() { - let _ = sender.try_send(BackendEvent::RequestPendingMessageEdit { session_id }); - } - } UiEvent::UpdatePendingMessage { message } => { debug!("UI: UpdatePendingMessage event with message: {:?}", message); // Update MessagesView's pending message @@ -753,9 +693,7 @@ impl Gpui { }); if is_session_errored { if let Some(session_id) = self.current_session_id.lock().unwrap().clone() { - if let Some(sender) = self.backend_event_sender.lock().unwrap().as_ref() { - let _ = sender.try_send(BackendEvent::ClearSessionError { session_id }); - } + self.cmd_clear_session_error(session_id); } } @@ -838,9 +776,7 @@ impl Gpui { debug!("UI: RefreshCurrentSession for {session_id}"); let current = self.current_session_id.lock().unwrap().clone(); if current.as_deref() == Some(session_id.as_str()) { - if let Some(sender) = self.backend_event_sender.lock().unwrap().as_ref() { - let _ = sender.try_send(BackendEvent::RefreshSession { session_id }); - } + self.cmd_refresh_session(session_id); } } @@ -874,55 +810,7 @@ impl Gpui { ); } - UiEvent::CancelSubAgent { tool_id } => { - debug!("UI: CancelSubAgent event for tool_id: {}", tool_id); - // Forward to backend with current session ID - if let Some(session_id) = self.current_session_id.lock().unwrap().clone() { - if let Some(sender) = self.backend_event_sender.lock().unwrap().as_ref() { - let _ = sender.try_send(BackendEvent::CancelSubAgent { - session_id, - tool_id, - }); - } - } else { - warn!("UI: CancelSubAgent requested but no active session"); - } - } - // === Session Branching Events === - UiEvent::StartMessageEdit { - session_id, - node_id, - } => { - debug!( - "UI: StartMessageEdit event for session {} node {}", - session_id, node_id - ); - // Forward to backend to get message content - if let Some(sender) = self.backend_event_sender.lock().unwrap().as_ref() { - let _ = sender.try_send(BackendEvent::StartMessageEdit { - session_id, - node_id, - }); - } - } - UiEvent::SwitchBranch { - session_id, - new_node_id, - } => { - debug!( - "UI: SwitchBranch event for session {} to node {}", - session_id, new_node_id - ); - // Forward to backend to perform branch switch - if let Some(sender) = self.backend_event_sender.lock().unwrap().as_ref() { - let _ = sender.try_send(BackendEvent::SwitchBranch { - session_id, - new_node_id, - }); - } - } - UiEvent::MessageEditReady { content, attachments, @@ -1055,30 +943,6 @@ impl Gpui { // Refresh UI to trigger RootView to process the pending edit cx.refresh(); } - UiEvent::BranchSwitched { - session_id, - messages, - tool_results, - plan, - } => { - debug!( - "UI: BranchSwitched event for session {} with {} messages", - session_id, - messages.len() - ); - // TODO: Update messages display with new branch content - // For now, we can reuse the SetMessages logic - self.process_ui_event_async( - UiEvent::SetMessages { - messages, - session_id: Some(session_id), - tool_results, - }, - cx, - ); - self.process_ui_event_async(UiEvent::UpdatePlan { plan }, cx); - } - UiEvent::UpdateBranchInfo { node_id, branch_info, @@ -1123,20 +987,11 @@ impl Gpui { *self.current_model.lock().unwrap() = Some(default_model.clone()); // Tell the backend to switch the active session's model // and update the default for future sessions - if let Some(sender) = - self.backend_event_sender.lock().unwrap().as_ref() + self.cmd_update_default_model(default_model.clone()); + if let Some(session_id) = + self.current_session_id.lock().unwrap().clone() { - let _ = sender.try_send(BackendEvent::UpdateDefaultModel { - model_name: default_model.clone(), - }); - if let Some(session_id) = - self.current_session_id.lock().unwrap().clone() - { - let _ = sender.try_send(BackendEvent::SwitchModel { - session_id, - model_name: default_model.clone(), - }); - } + self.cmd_switch_model(session_id, default_model.clone()); } } } diff --git a/crates/ui_gpui/src/app/mod.rs b/crates/ui_gpui/src/app/mod.rs index 40731d7e..ff53f70a 100644 --- a/crates/ui_gpui/src/app/mod.rs +++ b/crates/ui_gpui/src/app/mod.rs @@ -1,10 +1,11 @@ //! Application-level orchestration for the GPUI interface. //! -//! This module contains the event processing loop, backend communication, -//! the `UserInterface` trait implementation, and draft persistence — all the -//! "glue" that connects the UI components to the agent/session system. +//! This module contains the event processing loop, the typed session +//! commands (send side), the broadcast-stream bridge (receive side), and +//! draft persistence — all the "glue" that connects the UI components to +//! the agent/session system. -pub(super) mod backend; +pub(super) mod commands; pub(super) mod drafts; +pub(super) mod event_bridge; pub(super) mod event_loop; -pub(super) mod user_interface_impl; diff --git a/crates/ui_gpui/src/input/skill_completion.rs b/crates/ui_gpui/src/input/skill_completion.rs index 85ecbb09..2894d8a0 100644 --- a/crates/ui_gpui/src/input/skill_completion.rs +++ b/crates/ui_gpui/src/input/skill_completion.rs @@ -10,7 +10,7 @@ use std::time::Duration; use anyhow::Result; -use code_assistant_core::backend::SkillCatalogEntry; +use code_assistant_core::session::service::SkillCatalogEntry; use gpui::{Context, Task, Window}; use gpui_component::input::{InputState, RopeExt}; use gpui_component::Rope; diff --git a/crates/ui_gpui/src/lib.rs b/crates/ui_gpui/src/lib.rs index 0285912e..ac014a1b 100644 --- a/crates/ui_gpui/src/lib.rs +++ b/crates/ui_gpui/src/lib.rs @@ -12,8 +12,8 @@ pub mod terminal; pub mod tool_cards; use blocks::MessageContainer; -use code_assistant_core::backend::{BackendEvent, BackendResponse}; use code_assistant_core::persistence::{ChatMetadata, DraftStorage}; +use code_assistant_core::session::service::{SessionService, SkillCatalogEntry}; use code_assistant_core::types::PlanState; use code_assistant_core::ui::UiEvent; use gpui::{ @@ -27,7 +27,7 @@ use sandbox::SandboxPolicy; use shared::assets::Assets; use std::collections::HashMap; use std::sync::{Arc, Mutex}; -use tracing::{debug, error, trace, warn}; +use tracing::{debug, trace, warn}; actions!( code_assistant, @@ -63,11 +63,11 @@ pub struct Gpui { event_sender: Arc>>, event_receiver: Arc>>, event_task: Arc>>>, - session_event_task: Arc>>>, current_request_id: Arc>, - // Unified backend communication - backend_event_sender: Arc>>>, - backend_response_receiver: Arc>>>, + // UI→core command facade (installed by the wiring before run_app) + session_service: Arc>>, + // Executor handle for dispatching command futures (set in run_app) + background_executor: Arc>>, // Current chat state current_session_id: Arc>>, @@ -120,9 +120,9 @@ pub struct Gpui { config_generation: Arc, /// Skills available to the current session, cached for the `/skill` - /// input-area completion and submit-time invocation. Refreshed on session - /// load via `BackendEvent::ListSkills` / `BackendResponse::SkillsListed`. - skills: Arc>>, + /// input-area completion and submit-time invocation. Refreshed on + /// session load via [`Gpui::refresh_skills`]. + skills: Arc>>, } /// State for a pending message edit (for branching) @@ -322,7 +322,6 @@ impl Gpui { let message_queue = Arc::new(Mutex::new(Vec::new())); let plan_state = Arc::new(Mutex::new(None)); let event_task = Arc::new(Mutex::new(None::>)); - let session_event_task = Arc::new(Mutex::new(None::>)); let current_request_id = Arc::new(Mutex::new(0)); // Initialize tool block renderer registry @@ -372,10 +371,9 @@ impl Gpui { event_sender, event_receiver, event_task, - session_event_task, current_request_id, - backend_event_sender: Arc::new(Mutex::new(None)), - backend_response_receiver: Arc::new(Mutex::new(None)), + session_service: Arc::new(Mutex::new(None)), + background_executor: Arc::new(Mutex::new(None)), current_session_id: Arc::new(Mutex::new(None)), chat_sessions: Arc::new(Mutex::new(Vec::new())), @@ -435,6 +433,14 @@ impl Gpui { let app = gpui_platform::application().with_assets(Assets {}); app.run(move |cx| { + // Capture the background executor so session commands can be + // dispatched from any thread (see app/commands.rs). + *gpui_clone.background_executor.lock().unwrap() = + Some(cx.background_executor().clone()); + + // Subscribe to the core→UI broadcast stream (see app/event_bridge.rs) + gpui_clone.spawn_event_bridge(); + // Register our Gpui instance as a global cx.set_global(gpui_clone.clone()); @@ -536,48 +542,6 @@ impl Gpui { *task_guard = Some(task); } - // Spawn task to handle chat management responses from agent - let chat_gpui_clone = gpui_clone.clone(); - let chat_response_task = cx.spawn(async move |cx: &mut AsyncApp| { - // Wait a bit for the communication channels to be set up. - // NOTE: Use GPUI-native timer, not tokio::time::sleep, because - // this runs on the GPUI foreground executor, not a tokio runtime. - cx.background_executor() - .timer(std::time::Duration::from_millis(100)) - .await; - - loop { - // Check if we have a response receiver - let receiver_opt = chat_gpui_clone - .backend_response_receiver - .lock() - .unwrap() - .clone(); - if let Some(receiver) = receiver_opt { - match receiver.recv().await { - Ok(response) => { - chat_gpui_clone.handle_backend_response(response, cx); - } - Err(_) => { - // Channel closed, break the loop - break; - } - } - } else { - // No receiver yet, wait and try again - cx.background_executor() - .timer(std::time::Duration::from_millis(100)) - .await; - } - } - }); - - // Store the chat response task as well - { - let mut task_guard = gpui_clone.session_event_task.lock().unwrap(); - *task_guard = Some(chat_response_task); - } - // Register the GPUI terminal worker so that // GpuiTerminalCommandExecutor can create PTY terminals. cx.spawn(async move |cx: &mut AsyncApp| { @@ -652,38 +616,16 @@ impl Gpui { }); } - /// Setup unified backend communication channels - /// Returns channels for backend thread to receive events and send responses - pub fn setup_backend_communication( - &self, - ) -> ( - async_channel::Receiver, - async_channel::Sender, - ) { - let (event_tx, event_rx) = async_channel::unbounded::(); - let (response_tx, response_rx) = async_channel::unbounded::(); - - // Store channels for UI use - *self.backend_event_sender.lock().unwrap() = Some(event_tx); - *self.backend_response_receiver.lock().unwrap() = Some(response_rx); - - // Return the backend ends - (event_rx, response_tx) - } - /// Snapshot of the skills available to the current session. - pub(crate) fn skills(&self) -> Vec { + pub(crate) fn skills(&self) -> Vec { self.skills.lock().unwrap().clone() } - /// Request the skill catalog for `session_id` from the backend. The - /// response (`BackendResponse::SkillsListed`) populates the cached catalog - /// used by the `/skill` input-area completion. Used by startup paths that - /// connect a session without going through the in-app `LoadSession` flow. - pub fn refresh_skills(&self, session_id: String) { - if let Some(sender) = self.backend_event_sender.lock().unwrap().as_ref() { - let _ = sender.try_send(BackendEvent::ListSkills { session_id }); - } + /// Replace the cached skill catalog used by the `/skill` input-area + /// completion. Used by [`Gpui::refresh_skills`] and by startup paths + /// that fetch the catalog on the backend runtime. + pub fn set_skills(&self, skills: Vec) { + *self.skills.lock().unwrap() = skills; } // Helper to add an event to the queue diff --git a/crates/ui_gpui/src/main_screen/mod.rs b/crates/ui_gpui/src/main_screen/mod.rs index 58839087..56d814d3 100644 --- a/crates/ui_gpui/src/main_screen/mod.rs +++ b/crates/ui_gpui/src/main_screen/mod.rs @@ -11,7 +11,7 @@ use crate::shared::plan_banner; use crate::shared::settings; use crate::shared::theme; use crate::{CloseWindow, Gpui, UiEventSender, UiSettingsGlobal, WorktreeData}; -use code_assistant_core::backend::BackendEvent; + use code_assistant_core::ui::ui_events::UiEvent; use project_dialog::{NewProjectDialog, NewProjectDialogEvent}; @@ -26,7 +26,7 @@ use std::cell::Cell; use std::collections::HashMap; use std::rc::Rc; use std::time::{Duration, Instant}; -use tracing::{debug, error, trace, warn}; +use tracing::{debug, error, warn}; // --------------------------------------------------------------------------- // Sidebar animation helpers @@ -328,12 +328,10 @@ impl MainScreen { // Trigger refresh of chat list on startup pub fn refresh_chat_list(&mut self, cx: &mut Context) { debug!("Requesting chat list refresh"); - // Request session list from agent via Gpui global - if let Some(sender) = cx.try_global::() { - trace!("Sending RefreshChatList event"); - let _ = sender.0.try_send(UiEvent::RefreshChatList); + if let Some(gpui) = cx.try_global::() { + gpui.cmd_refresh_chat_list(); } else { - warn!("No UiEventSender global available"); + warn!("No Gpui global available"); } } @@ -462,13 +460,7 @@ impl MainScreen { let gpui = cx .try_global::() .expect("Failed to obtain Gpui global"); - if let Some(sender) = gpui.backend_event_sender.lock().unwrap().as_ref() { - let _ = sender.try_send(BackendEvent::InvokeSkill { - session_id: session_id.clone(), - scope: scope.clone(), - name: name.clone(), - }); - } + gpui.cmd_invoke_skill(session_id.clone(), scope.clone(), name.clone()); } } @@ -496,11 +488,7 @@ impl MainScreen { // Cancel edit mode - reload original messages for this session if let Some(session_id) = &self.current_session_id { if let Some(gpui) = cx.try_global::() { - if let Some(sender) = gpui.backend_event_sender.lock().unwrap().as_ref() { - let _ = sender.try_send(BackendEvent::CancelMessageEdit { - session_id: session_id.clone(), - }); - } + gpui.cmd_cancel_message_edit(session_id.clone()); } } } @@ -508,10 +496,7 @@ impl MainScreen { // Handle cancel/stop request if let Some(session_id) = &self.current_session_id { if let Some(gpui) = cx.try_global::() { - gpui.session_stop_requests - .lock() - .unwrap() - .insert(session_id.clone()); + gpui.cmd_request_stop(session_id.clone()); } } cx.notify(); @@ -531,14 +516,7 @@ impl MainScreen { let gpui = cx .try_global::() .expect("Failed to obtain Gpui global"); - if let Some(sender) = gpui.backend_event_sender.lock().unwrap().as_ref() { - let _ = sender.try_send(BackendEvent::SwitchModel { - session_id: session_id.clone(), - model_name: model_name.clone(), - }); - } else { - error!("Failed to lock backend event sender"); - } + gpui.cmd_switch_model(session_id.clone(), model_name.clone()); } } @@ -547,14 +525,7 @@ impl MainScreen { let gpui = cx .try_global::() .expect("Failed to obtain Gpui global"); - if let Some(sender) = gpui.backend_event_sender.lock().unwrap().as_ref() { - let _ = sender.try_send(BackendEvent::ChangeSandboxPolicy { - session_id: session_id.clone(), - policy: policy.clone(), - }); - } else { - error!("Failed to lock backend event sender"); - } + gpui.cmd_change_sandbox_policy(session_id.clone(), policy.clone()); } } InputAreaEvent::WorktreeSwitchedToLocal => { @@ -562,13 +533,7 @@ impl MainScreen { let gpui = cx .try_global::() .expect("Failed to obtain Gpui global"); - if let Some(sender) = gpui.backend_event_sender.lock().unwrap().as_ref() { - let _ = sender.try_send(BackendEvent::SwitchWorktree { - session_id: session_id.clone(), - worktree_path: None, - branch: None, - }); - } + gpui.cmd_switch_worktree(session_id.clone(), None, None); } } InputAreaEvent::WorktreeSwitched { @@ -579,13 +544,11 @@ impl MainScreen { let gpui = cx .try_global::() .expect("Failed to obtain Gpui global"); - if let Some(sender) = gpui.backend_event_sender.lock().unwrap().as_ref() { - let _ = sender.try_send(BackendEvent::SwitchWorktree { - session_id: session_id.clone(), - worktree_path: Some(worktree_path.clone()), - branch: Some(branch.clone()), - }); - } + gpui.cmd_switch_worktree( + session_id.clone(), + Some(worktree_path.clone()), + Some(branch.clone()), + ); } } @@ -601,13 +564,7 @@ impl MainScreen { let gpui = cx .try_global::() .expect("Failed to obtain Gpui global"); - if let Some(sender) = gpui.backend_event_sender.lock().unwrap().as_ref() { - let _ = sender.try_send(BackendEvent::CreateWorktree { - session_id: session_id.clone(), - branch_name, - base_branch: None, - }); - } + gpui.cmd_create_worktree(session_id.clone(), branch_name); } } InputAreaEvent::WorktreeRefreshRequested => { @@ -615,11 +572,7 @@ impl MainScreen { let gpui = cx .try_global::() .expect("Failed to obtain Gpui global"); - if let Some(sender) = gpui.backend_event_sender.lock().unwrap().as_ref() { - let _ = sender.try_send(BackendEvent::ListBranchesAndWorktrees { - session_id: session_id.clone(), - }); - } + gpui.cmd_list_branches_and_worktrees(session_id.clone()); } } } @@ -671,57 +624,37 @@ impl MainScreen { let gpui = cx .try_global::() .expect("Failed to obtain Gpui global"); - if let Some(sender) = gpui.backend_event_sender.lock().unwrap().as_ref() { - let _ = sender.try_send(BackendEvent::DeleteSession { - session_id: session_id.clone(), - }); - } + gpui.cmd_delete_session(session_id.clone()); return; } let gpui = cx .try_global::() .expect("Failed to obtain Gpui global"); - if let Some(sender) = gpui.backend_event_sender.lock().unwrap().as_ref() { - match event { - SessionSidebarEvent::SessionSelected { session_id } => { - // If the session's draft is in edit mode, load it already - // truncated to the branch parent so the edit view is - // restored in a single event (no full-then-truncate flash). - let edit_until_node_id = gpui - .load_draft_for_session(session_id) - .and_then(|(_, _, anchor)| anchor); - let _ = sender.try_send(BackendEvent::LoadSession { - session_id: session_id.clone(), - edit_until_node_id, - }); - // Refresh the skill catalog for the `/skill` picker. - let _ = sender.try_send(BackendEvent::ListSkills { - session_id: session_id.clone(), - }); - } - SessionSidebarEvent::NewSessionRequested { - name, - initial_project, - } => { - let _ = sender.try_send(BackendEvent::CreateNewSession { - name: name.clone(), - initial_project: initial_project.clone(), - }); - } + match event { + SessionSidebarEvent::SessionSelected { session_id } => { + // If the session's draft is in edit mode, load it already + // truncated to the branch parent so the edit view is + // restored in a single event (no full-then-truncate flash). + let edit_until_node_id = gpui + .load_draft_for_session(session_id) + .and_then(|(_, _, anchor)| anchor); + gpui.cmd_load_session(session_id.clone(), edit_until_node_id); + } + SessionSidebarEvent::NewSessionRequested { + name, + initial_project, + } => { + gpui.cmd_create_session(name.clone(), initial_project.clone()); + } - SessionSidebarEvent::PersistProjectRequested { project_name } => { - let _ = sender.try_send(BackendEvent::PersistProject { - project_name: project_name.clone(), - }); - } - SessionSidebarEvent::SessionDeleteRequested { .. } - | SessionSidebarEvent::AddProjectRequested => { - // Handled above - } + SessionSidebarEvent::PersistProjectRequested { project_name } => { + gpui.cmd_persist_project(project_name.clone()); + } + SessionSidebarEvent::SessionDeleteRequested { .. } + | SessionSidebarEvent::AddProjectRequested => { + // Handled above } - } else { - error!("Failed to lock backend event sender"); } } @@ -737,14 +670,11 @@ impl MainScreen { return; } - // Send user message event if we have an active session - if let Some(sender) = cx.try_global::() { + // Send user message if we have an active session + if let Some(gpui) = cx.try_global::() { // Check if agent is running by looking at activity state - let current_activity_state = if let Some(gpui) = cx.try_global::() { - gpui.current_session_activity_state.lock().unwrap().clone() - } else { - None - }; + let current_activity_state = + gpui.current_session_activity_state.lock().unwrap().clone(); if current_activity_state .as_ref() @@ -782,11 +712,11 @@ impl MainScreen { content, attachments.len() ); - let _ = sender.0.try_send(UiEvent::QueueUserMessage { - message: content.clone(), - session_id: session_id.to_string(), - attachments: attachments.clone(), - }); + gpui.cmd_queue_user_message( + session_id.to_string(), + content.clone(), + attachments.clone(), + ); } else { // Send message normally (agent is idle) tracing::info!( @@ -796,12 +726,12 @@ impl MainScreen { attachments.len(), branch_parent_id ); - let _ = sender.0.try_send(UiEvent::SendUserMessage { - message: content.clone(), - session_id: session_id.to_string(), - attachments: attachments.clone(), + gpui.cmd_send_user_message( + session_id.to_string(), + content.clone(), + attachments.clone(), branch_parent_id, - }); + ); } } } @@ -840,13 +770,10 @@ impl MainScreen { _window: &mut gpui::Window, cx: &mut Context, ) { - // Add current session to stop requests + // Request the agent of the current session to stop if let Some(session_id) = &self.current_session_id { if let Some(gpui) = cx.try_global::() { - gpui.session_stop_requests - .lock() - .unwrap() - .insert(session_id.clone()); + gpui.cmd_request_stop(session_id.clone()); } } cx.notify(); @@ -922,16 +849,10 @@ impl MainScreen { match event { NewProjectDialogEvent::Confirmed { name, path } => { debug!("New project confirmed: name='{}', path={:?}", name, path); - // Send AddProject to backend let gpui = cx .try_global::() .expect("Failed to obtain Gpui global"); - if let Some(sender) = gpui.backend_event_sender.lock().unwrap().as_ref() { - let _ = sender.try_send(BackendEvent::AddProject { - name: name.clone(), - path: path.clone(), - }); - } + gpui.cmd_add_project(name.clone(), path.clone()); // Close dialog self.new_project_dialog = None; self._new_project_dialog_subscription = None; @@ -978,7 +899,7 @@ impl MainScreen { } // Read everything we need from Gpui in a scoped borrow, then drop the ref - let (input_value, attachments, editing_branch_parent_id, backend_sender) = { + let (input_value, attachments, editing_branch_parent_id, gpui_handle) = { let gpui = cx.try_global::(); let (input_value, attachments, editing_branch_parent_id) = if let ( @@ -1007,10 +928,10 @@ impl MainScreen { ("".to_string(), Vec::new(), None) }; - // Extract the backend sender and clear worktree data while we hold the ref - let backend_sender = if let Some(gpui) = &gpui { + // Clear worktree data while we hold the ref + let gpui_handle = if let Some(gpui) = &gpui { *gpui.current_worktree_data.lock().unwrap() = None; - gpui.backend_event_sender.lock().unwrap().as_ref().cloned() + Some((*gpui).clone()) } else { None }; @@ -1019,7 +940,7 @@ impl MainScreen { input_value, attachments, editing_branch_parent_id, - backend_sender, + gpui_handle, ) // `gpui` borrow of `cx` dropped here }; @@ -1040,10 +961,8 @@ impl MainScreen { }); // Request fresh worktree listing for the new session - if let (Some(session_id), Some(sender)) = (new_session_id.as_ref(), &backend_sender) { - let _ = sender.try_send(BackendEvent::ListBranchesAndWorktrees { - session_id: session_id.clone(), - }); + if let (Some(session_id), Some(gpui)) = (new_session_id.as_ref(), &gpui_handle) { + gpui.cmd_list_branches_and_worktrees(session_id.clone()); } // Reset the worktree selector to "Local" while waiting for fresh data diff --git a/crates/ui_gpui/src/messages/branch_switcher.rs b/crates/ui_gpui/src/messages/branch_switcher.rs index 36c532b1..9704d6ea 100644 --- a/crates/ui_gpui/src/messages/branch_switcher.rs +++ b/crates/ui_gpui/src/messages/branch_switcher.rs @@ -97,15 +97,8 @@ impl RenderOnce for BranchSwitcherElement { base.hover(|s| s.bg(cx.theme().accent.opacity(0.15))) .on_click(move |_event, _window, cx| { if let Some(node_id) = prev_node_id { - if let Some(sender) = - cx.try_global::() - { - let _ = sender.0.try_send( - code_assistant_core::ui::UiEvent::SwitchBranch { - session_id: session_id.clone(), - new_node_id: node_id, - }, - ); + if let Some(gpui) = cx.try_global::() { + gpui.cmd_switch_branch(session_id.clone(), node_id); } } }) @@ -145,14 +138,10 @@ impl RenderOnce for BranchSwitcherElement { base.hover(|s| s.bg(cx.theme().accent.opacity(0.25))) .on_click(move |_event, _window, cx| { if let Some(node_id) = next_node_id { - if let Some(sender) = - cx.try_global::() - { - let _ = sender.0.try_send( - code_assistant_core::ui::UiEvent::SwitchBranch { - session_id: session_id_for_next.clone(), - new_node_id: node_id, - }, + if let Some(gpui) = cx.try_global::() { + gpui.cmd_switch_branch( + session_id_for_next.clone(), + node_id, ); } } diff --git a/crates/ui_gpui/src/messages/message_item.rs b/crates/ui_gpui/src/messages/message_item.rs index e98ae0a0..bc0da09f 100644 --- a/crates/ui_gpui/src/messages/message_item.rs +++ b/crates/ui_gpui/src/messages/message_item.rs @@ -105,14 +105,8 @@ pub fn render_message( if let (Some(session_id), Some(node_id)) = (session_id_for_edit.clone(), node_id_for_edit) { - if let Some(sender) = cx.try_global::() - { - let _ = sender.0.try_send( - code_assistant_core::ui::UiEvent::StartMessageEdit { - session_id, - node_id, - }, - ); + if let Some(gpui) = cx.try_global::() { + gpui.cmd_start_message_edit(session_id, node_id); } } }) diff --git a/crates/ui_gpui/src/messages/mod.rs b/crates/ui_gpui/src/messages/mod.rs index 76429c24..de2ff940 100644 --- a/crates/ui_gpui/src/messages/mod.rs +++ b/crates/ui_gpui/src/messages/mod.rs @@ -4,7 +4,7 @@ mod message_item; mod scroll; use crate::Gpui; -use code_assistant_core::backend::BackendEvent; + use code_assistant_core::session::instance::SessionActivityState; use gpui::{ @@ -673,13 +673,7 @@ impl Render for MessagesView { return; }; if let Some(gpui) = cx.try_global::() { - if let Some(sender) = - gpui.backend_event_sender.lock().unwrap().as_ref() - { - let _ = sender.try_send(BackendEvent::ResumeSession { - session_id, - }); - } + gpui.cmd_resume_session(session_id); } cx.notify(); })), diff --git a/crates/ui_gpui/src/settings_screen/skills_section.rs b/crates/ui_gpui/src/settings_screen/skills_section.rs index 63af6aa8..b5d84cbd 100644 --- a/crates/ui_gpui/src/settings_screen/skills_section.rs +++ b/crates/ui_gpui/src/settings_screen/skills_section.rs @@ -278,8 +278,8 @@ impl Render for SkillsSection { .text_xs() .text_color(cx.theme().muted_foreground) .child(SharedString::from(format!( - "Add skills under {}/skills", - code_assistant_core::config_dir::config_dir().display() + "Add user skills under {}", + code_assistant_core::config::user_skills_root().display() ))), ), ) diff --git a/crates/ui_gpui/src/tool_cards/sub_agent_card.rs b/crates/ui_gpui/src/tool_cards/sub_agent_card.rs index 1f56b98c..0f95bf23 100644 --- a/crates/ui_gpui/src/tool_cards/sub_agent_card.rs +++ b/crates/ui_gpui/src/tool_cards/sub_agent_card.rs @@ -230,12 +230,8 @@ impl ToolBlockRenderer for SubAgentCardRenderer { .hover(|s| s.bg(theme.danger.opacity(0.15)).text_color(theme.danger)) .on_click(cx.listener(move |_view, _event: &ClickEvent, _window, cx| { cx.stop_propagation(); - if let Some(sender) = cx.try_global::() { - let _ = sender.0.try_send( - code_assistant_core::ui::UiEvent::CancelSubAgent { - tool_id: tool_id_cancel.clone(), - }, - ); + if let Some(gpui) = cx.try_global::() { + gpui.cmd_cancel_sub_agent(tool_id_cancel.clone()); } })) .child("Cancel"), diff --git a/crates/ui_terminal/src/app.rs b/crates/ui_terminal/src/app.rs index 0ccbeb3b..6b8e4bad 100644 --- a/crates/ui_terminal/src/app.rs +++ b/crates/ui_terminal/src/app.rs @@ -9,15 +9,13 @@ use crate::{ ui::TerminalUI, }; use anyhow::Result; -use code_assistant_core::backend::{ - handle_backend_events, BackendEvent, BackendResponse, BackendRuntimeOptions, -}; use code_assistant_core::config; use code_assistant_core::config::AgentRunConfig; use code_assistant_core::persistence::FileSessionPersistence; use code_assistant_core::session::manager::SessionManager; +use code_assistant_core::session::service::{AgentRuntimeOptions, SessionService}; use code_assistant_core::session::SessionConfig; -use code_assistant_core::ui::UserInterface; +use code_assistant_core::ui::{UiEvent, UserInterface}; use crossterm::cursor::MoveTo; use crossterm::event::{Event, EventStream}; @@ -97,18 +95,172 @@ fn clear_slash_command_word(textarea: &mut TextArea) { textarea.replace_range(line_start..end, ""); } +/// The terminal UI's session commands: thin wrappers around +/// [`SessionService`] calls that apply each typed result to the app state / +/// UI. Calls run in spawned tasks so the input loop never blocks on the +/// backend. +#[derive(Clone)] +struct Actions { + service: SessionService, + ui: Arc, + app_state: Arc>, + redraw_tx: tokio::sync::watch::Sender<()>, +} + +impl Actions { + fn display_error(&self, message: String) { + let ui = self.ui.clone(); + tokio::spawn(async move { + let _ = ui.send_event(UiEvent::DisplayError { message }).await; + }); + } + + fn send_user_message( + &self, + session_id: String, + message: String, + attachments: Vec, + ) { + let this = self.clone(); + tokio::spawn(async move { + if let Err(e) = this + .service + .send_user_message(session_id, message, attachments, None) + .await + { + this.display_error(format!("Failed to send message: {e:#}")); + } + }); + } + + fn queue_user_message( + &self, + session_id: String, + message: String, + attachments: Vec, + ) { + let this = self.clone(); + tokio::spawn(async move { + match this + .service + .queue_user_message(session_id, message, attachments) + .await + { + Ok(pending) => { + let _ = this + .ui + .send_event(UiEvent::UpdatePendingMessage { message: pending }) + .await; + } + Err(e) => this.display_error(format!("Failed to queue message: {e:#}")), + } + }); + } + + fn switch_model(&self, session_id: String, model_name: String) { + let this = self.clone(); + tokio::spawn(async move { + match this + .service + .switch_model(session_id, model_name.clone()) + .await + { + Ok(result) => { + let mut state = this.app_state.lock().await; + state.update_current_model(Some(model_name.clone())); + let info = match result.warning { + Some(w) => format!("Switched to model: {model_name} ({w})"), + None => format!("Switched to model: {model_name}"), + }; + state.set_info_message(Some(info)); + drop(state); + let _ = this.redraw_tx.send(()); + } + Err(e) => this.display_error(format!("{e:#}")), + } + }); + } + + /// Ask the running agent to stop at its next streaming checkpoint. + fn request_stop(&self, session_id: String) { + let this = self.clone(); + tokio::spawn(async move { + if let Err(e) = this.service.request_stop(session_id).await { + tracing::debug!("Failed to request agent stop: {e:#}"); + } + }); + } + + fn clear_context(&self, session_id: String) { + let this = self.clone(); + tokio::spawn(async move { + if let Err(e) = this.service.clear_context(session_id).await { + this.display_error(format!("Failed to clear context: {e:#}")); + } + }); + } + + fn compact_context(&self, session_id: String) { + let this = self.clone(); + tokio::spawn(async move { + if let Err(e) = this.service.compact_context(session_id).await { + this.display_error(format!("{e:#}")); + } + }); + } + + fn invoke_skill(&self, session_id: String, scope: String, name: String) { + let this = self.clone(); + tokio::spawn(async move { + if let Err(e) = this.service.invoke_skill(session_id, scope, name).await { + this.display_error(format!("{e:#}")); + } + }); + } + + /// Fetch the session list and publish it to the UI. + fn refresh_chat_list(&self) { + let this = self.clone(); + tokio::spawn(async move { + match this.service.list_sessions().await { + Ok(sessions) => { + let _ = this + .ui + .send_event(UiEvent::UpdateChatList { sessions }) + .await; + } + Err(e) => this.display_error(format!("Failed to list sessions: {e:#}")), + } + }); + } + + /// Fetch the skill catalog for the `/skill` picker and cache it. + fn refresh_skills(&self, session_id: String) { + let this = self.clone(); + tokio::spawn(async move { + match this.service.list_skills(session_id).await { + Ok(skills) => { + this.app_state.lock().await.skills = skills; + } + Err(e) => { + debug!("Failed to list skills: {e:#}"); + } + } + }); + } +} + /// Run the side-effects for a [`CommandResult`] produced by either an inline -/// `/cmd` submission or a popup commit. Returns an optional -/// [`BackendEvent`] the caller should send. +/// `/cmd` submission or a popup commit. async fn handle_command_result( cmd: crate::commands::CommandResult, app_state: &Arc>, renderer: &Arc>, - backend_event_tx: &async_channel::Sender, -) -> Option { + actions: &Actions, +) { use crate::commands::CommandResult; match cmd { - CommandResult::Continue => None, + CommandResult::Continue => {} CommandResult::Help(_) => { let processor = CommandProcessor::new().ok(); let text = processor @@ -118,7 +270,6 @@ async fn handle_command_result( }) .unwrap_or_default(); app_state.lock().await.set_info_message(Some(text)); - None } CommandResult::ListModels => { if let Ok(p) = CommandProcessor::new() { @@ -127,7 +278,6 @@ async fn handle_command_result( .await .set_info_message(Some(p.get_models_list())); } - None } CommandResult::ListProviders => { if let Ok(p) = CommandProcessor::new() { @@ -136,7 +286,6 @@ async fn handle_command_result( .await .set_info_message(Some(p.get_providers_list())); } - None } CommandResult::SwitchModel(model_name) => { let session_id = { @@ -144,19 +293,12 @@ async fn handle_command_result( state.current_session_id.clone() }; if let Some(session_id) = session_id { - let mut state = app_state.lock().await; - state.update_current_model(Some(model_name.clone())); - state.set_info_message(Some(format!("Switched to model: {model_name}"))); - Some(BackendEvent::SwitchModel { - session_id, - model_name, - }) + actions.switch_model(session_id, model_name); } else { app_state .lock() .await .set_info_message(Some("No active session to switch model".to_string())); - None } } CommandResult::ShowCurrentModel => { @@ -166,7 +308,6 @@ async fn handle_command_result( None => "No model selected".to_string(), }; app_state.lock().await.set_info_message(Some(message)); - None } CommandResult::TogglePlan => { let (plan_state, expanded, overlay_active) = { @@ -180,15 +321,18 @@ async fn handle_command_result( } renderer_guard.set_plan_expanded(expanded); renderer_guard.set_overlay_active(overlay_active); - None } CommandResult::ClearContext => { let session_id = app_state.lock().await.current_session_id.clone(); - session_id.map(|session_id| BackendEvent::ClearContext { session_id }) + if let Some(session_id) = session_id { + actions.clear_context(session_id); + } } CommandResult::CompactContext => { let session_id = app_state.lock().await.current_session_id.clone(); - session_id.map(|session_id| BackendEvent::CompactContext { session_id }) + if let Some(session_id) = session_id { + actions.compact_context(session_id); + } } CommandResult::OpenSkillPicker => { @@ -205,7 +349,6 @@ async fn handle_command_result( crate::slash_popup::SkillPickerPopup::from_entries(skills), )); } - None } CommandResult::InvokeSkill { scope, name } => { let session_id = app_state.lock().await.current_session_id.clone(); @@ -214,7 +357,7 @@ async fn handle_command_result( .lock() .await .set_info_message(Some("No active session to activate a skill".to_string())); - return None; + return; }; // Resolve the scope token: use the explicit one from the picker, or @@ -236,18 +379,13 @@ async fn handle_command_result( .lock() .await .set_info_message(Some(format!("Activating skill: {name}"))); - Some(BackendEvent::InvokeSkill { - session_id, - scope, - name, - }) + actions.invoke_skill(session_id, scope, name); } None => { app_state .lock() .await .set_info_message(Some(format!("No skill named '{name}' was found"))); - None } } } @@ -256,9 +394,6 @@ async fn handle_command_result( .lock() .await .set_info_message(Some(format!("Error: {error}"))); - // Suppress unused warning in this branch. - let _ = backend_event_tx; - None } } } @@ -269,7 +404,7 @@ async fn event_loop( renderer: Arc>, app_state: Arc>, cancel_flag: Arc, - backend_event_tx: async_channel::Sender, + actions: Actions, mut tui: tui::Tui, mut redraw_rx: tokio::sync::watch::Receiver<()>, ) -> Result<()> { @@ -395,17 +530,17 @@ async fn event_loop( if let Some(session_id) = current_session_id { cancel_flag.store(true, Ordering::SeqCst); debug!( - "Escape pressed - cancellation flag set for session {} (state: {:?})", + "Escape pressed - requesting stop for session {} (state: {:?})", session_id, activity_state ); - let mut state = app_state.lock().await; if activity_state.as_ref().is_some_and(|s| s.is_terminal()) { state.set_info_message(Some( "No agent is currently running.".to_string(), )); } else { + actions.request_stop(session_id.clone()); state.set_info_message(Some( "Cancellation requested...".to_string(), )); @@ -429,34 +564,25 @@ async fn event_loop( state.activity_state.clone() }; - - let event = match activity_state { - Some(ref s) if s.is_terminal() => { - cancel_flag.store(false, Ordering::SeqCst); - BackendEvent::SendUserMessage { - session_id, - message, - attachments, - branch_parent_id: None, // Terminal UI doesn't support branching yet - } - } - None => { - cancel_flag.store(false, Ordering::SeqCst); - BackendEvent::SendUserMessage { - session_id, - message, - attachments, - branch_parent_id: None, // Terminal UI doesn't support branching yet - } - } - _ => BackendEvent::QueueUserMessage { + // While an agent is running the message is + // queued; otherwise it starts a new turn. + let agent_idle = activity_state + .as_ref() + .is_none_or(|s| s.is_terminal()); + if agent_idle { + cancel_flag.store(false, Ordering::SeqCst); + actions.send_user_message( session_id, message, attachments, - }, - }; - - let _ = backend_event_tx.send(event).await; + ); + } else { + actions.queue_user_message( + session_id, + message, + attachments, + ); + } } } KeyEventResult::Continue => { @@ -475,19 +601,9 @@ async fn event_loop( }; if let Some(session_id) = current_session_id { - let event = BackendEvent::SwitchModel { - session_id, - model_name: model_name.clone(), - }; - - let _ = backend_event_tx.send(event).await; - - // Update state - let mut state = app_state.lock().await; - state.update_current_model(Some(model_name.clone())); - state.set_info_message(Some(format!( - "Switched to model: {model_name}", - ))); + // State and info message are updated when + // the switch succeeds (see Actions). + actions.switch_model(session_id, model_name); } else { let mut state = app_state.lock().await; state.set_info_message(Some( @@ -531,9 +647,7 @@ async fn event_loop( state.current_session_id.clone() }; if let Some(session_id) = current_session_id { - let _ = backend_event_tx - .send(BackendEvent::ClearContext { session_id }) - .await; + actions.clear_context(session_id); } } KeyEventResult::CompactContext => { @@ -542,34 +656,29 @@ async fn event_loop( state.current_session_id.clone() }; if let Some(session_id) = current_session_id { - let _ = backend_event_tx - .send(BackendEvent::CompactContext { session_id }) - .await; + actions.compact_context(session_id); } } KeyEventResult::OpenSkillPicker => { // Inline `/skill` (popup not active): reuse the // command-result handler to open the picker. - let _ = handle_command_result( + handle_command_result( crate::commands::CommandResult::OpenSkillPicker, &app_state, &renderer, - &backend_event_tx, + &actions, ) .await; } KeyEventResult::InvokeSkill { scope, name } => { - if let Some(event) = handle_command_result( + handle_command_result( crate::commands::CommandResult::InvokeSkill { scope, name }, &app_state, &renderer, - &backend_event_tx, + &actions, ) - .await - { - let _ = backend_event_tx.send(event).await; - } + .await; } KeyEventResult::SlashPrefixChanged(query) => { handle_slash_prefix_changed( @@ -647,17 +756,8 @@ async fn event_loop( // composer line (the slash word) and dispatch the // command via the same path as inline /commands. clear_slash_command_word(&mut input_manager.textarea); - if let Some(next_event) = handle_command_result( - cmd, - &app_state, - &renderer, - &backend_event_tx, - ) - .await - { - // Forward any backend event the command produced. - let _ = backend_event_tx.send(next_event).await; - } + handle_command_result(cmd, &app_state, &renderer, &actions) + .await; } } } @@ -714,7 +814,7 @@ impl TerminalTuiApp { pub async fn run( &self, config: &AgentRunConfig, - command_executor_factory: code_assistant_core::backend::CommandExecutorFactory, + command_executor_factory: code_assistant_core::session::service::CommandExecutorFactory, ) -> Result<()> { let app_state = Arc::new(Mutex::new(AppState::new())); let root_path = config.path.canonicalize()?; @@ -738,11 +838,13 @@ impl TerminalTuiApp { }; // Create session manager + let events = code_assistant_core::session::event_stream::EventStream::new(); let session_manager = SessionManager::new( session_persistence, session_config_template, config.model.clone(), code_assistant_core::tools::default_registry(), + events.clone(), ); let multi_session_manager = Arc::new(Mutex::new(session_manager)); @@ -755,32 +857,91 @@ impl TerminalTuiApp { async_channel::unbounded::(); terminal_ui.set_event_sender(ui_event_tx); - // Setup backend communication channels - let (backend_event_tx, backend_event_rx) = async_channel::unbounded::(); - let (backend_response_tx, backend_response_rx) = - async_channel::unbounded::(); - - // Spawn backend handler - let backend_task = { - let multi_session_manager = multi_session_manager.clone(); - let runtime_options = Arc::new(BackendRuntimeOptions { + // Create the session command service and spawn its worker. This + // frontend consumes the broadcast stream (see the bridge task below). + let (service, service_worker) = SessionService::new( + multi_session_manager.clone(), + Arc::new(AgentRuntimeOptions { record_path: config.record.clone(), playback_path: config.playback.clone(), fast_playback: config.fast_playback, command_executor_factory, - }); - let ui = ui.clone(); + }), + events, + ); + let backend_task = tokio::spawn(service_worker); + // Bridge: subscribe to the core→UI broadcast stream and feed the + // terminal's rendering pipeline. Single-session app, so everything + // scoped to the current session (or app-scoped) passes. + { + let terminal_ui = terminal_ui.clone(); + let app_state = app_state.clone(); + let service_for_bridge = service.clone(); + let mut subscription = service.subscribe(); tokio::spawn(async move { - handle_backend_events( - backend_event_rx, - backend_response_tx, - multi_session_manager, - runtime_options, - ui, - ) - .await; - }) + use code_assistant_core::session::instance::SessionActivityState; + use code_assistant_core::session::{EventPayload, StreamError}; + loop { + match subscription.recv().await { + Ok(event) => { + let current = app_state.lock().await.current_session_id.clone(); + let relevant = + event.session_id.is_none() || event.session_id == current; + if !relevant { + continue; + } + match event.payload { + EventPayload::Fragment(fragment) => { + let _ = terminal_ui.display_fragment(&fragment); + } + EventPayload::Ui(ui_event) => { + // Drive the rate-limit spinner from activity + // transitions (used to be trait callbacks). + if let UiEvent::UpdateSessionActivityState { + activity_state, + .. + } = &ui_event + { + match activity_state { + SessionActivityState::RateLimited { + seconds_remaining, + } => terminal_ui.notify_rate_limit(*seconds_remaining), + _ => terminal_ui.clear_rate_limit(), + } + } + let _ = terminal_ui.send_event(ui_event).await; + } + } + } + Err(StreamError::Lagged { missed }) => { + tracing::warn!("Event stream lagged ({missed} missed) — resyncing"); + let current = app_state.lock().await.current_session_id.clone(); + if let Some(session_id) = current { + if let Ok(snapshot) = + service_for_bridge.load_session(session_id, None).await + { + for event in snapshot.connect_events() { + let _ = terminal_ui.send_event(event).await; + } + } + } + } + Err(StreamError::Closed) => break, + } + } + }); + } + + // Create the redraw notification channel early so `Actions` can wake + // the event loop after async state updates. + let (redraw_tx, redraw_rx) = tokio::sync::watch::channel::<()>(()); + + let actions = Actions { + service: service.clone(), + ui: ui.clone(), + app_state: app_state.clone(), + redraw_tx: redraw_tx.clone(), }; // Determine which session to use and load it @@ -795,14 +956,21 @@ impl TerminalTuiApp { if let Some(existing_session_id) = latest_session_id { debug!("Continuing from latest session: {}", existing_session_id); - - backend_event_tx - .send(BackendEvent::LoadSession { - session_id: existing_session_id.clone(), - edit_until_node_id: None, - }) - .await?; - session_id = Some(existing_session_id); + match service + .load_session(existing_session_id.clone(), None) + .await + { + Ok(snapshot) => { + for event in snapshot.connect_events() { + let _ = terminal_ui.send_event(event).await; + } + session_id = Some(existing_session_id); + } + Err(e) => { + // Fall through to creating a fresh session + debug!("Failed to continue session {existing_session_id}: {e:#}"); + } + } } else { debug!("No previous session found"); } @@ -811,35 +979,13 @@ impl TerminalTuiApp { // Create new session if we don't have one yet if session_id.is_none() { debug!("Creating new session"); - - backend_event_tx - .send(BackendEvent::CreateNewSession { - name: None, - initial_project: None, - }) - .await?; - - match backend_response_rx.recv().await? { - BackendResponse::SessionCreated { - session_id: new_session_id, - } => { - debug!("Created new session: {}", new_session_id); - - backend_event_tx - .send(BackendEvent::LoadSession { - session_id: new_session_id.clone(), - edit_until_node_id: None, - }) - .await?; - session_id = Some(new_session_id); - } - BackendResponse::Error { message } => { - return Err(anyhow::anyhow!("Failed to create session: {message}")); - } - _ => { - return Err(anyhow::anyhow!("Unexpected response when creating session")); - } + let new_session_id = service.create_session(None, None).await?; + debug!("Created new session: {}", new_session_id); + let snapshot = service.load_session(new_session_id.clone(), None).await?; + for event in snapshot.connect_events() { + let _ = terminal_ui.send_event(event).await; } + session_id = Some(new_session_id); } let session_id = session_id.expect("Session ID should be set at this point"); @@ -853,12 +999,10 @@ impl TerminalTuiApp { } // Kick off a session list refresh (optional but useful) - let _ = backend_event_tx.try_send(BackendEvent::ListSessions); + actions.refresh_chat_list(); // Fetch the skill catalog for the `/skill` picker. - let _ = backend_event_tx.try_send(BackendEvent::ListSkills { - session_id: session_id.clone(), - }); + actions.refresh_skills(session_id.clone()); // Spawn a background task to process UI events from display fragments { @@ -870,120 +1014,6 @@ impl TerminalTuiApp { }); } - // Spawn a background task to translate backend responses into UiEvents - { - let ui_clone = ui.clone(); - let app_state_clone = app_state.clone(); - tokio::spawn(async move { - while let Ok(resp) = backend_response_rx.recv().await { - match resp { - BackendResponse::SessionsListed { sessions } => { - let _ = ui_clone - .send_event(code_assistant_core::ui::UiEvent::UpdateChatList { - sessions, - }) - .await; - } - BackendResponse::SkillsListed { - session_id: _, - skills, - } => { - // Cache the catalog for the `/skill` picker. - app_state_clone.lock().await.skills = skills; - } - BackendResponse::PendingMessageUpdated { - session_id: _, - message, - } => { - let _ = ui_clone - .send_event( - code_assistant_core::ui::UiEvent::UpdatePendingMessage { - message, - }, - ) - .await; - } - BackendResponse::PendingMessageForEdit { - session_id: _, - message: _, - } => { - // For now, just clear pending in UI - let _ = ui_clone - .send_event( - code_assistant_core::ui::UiEvent::UpdatePendingMessage { - message: None, - }, - ) - .await; - } - BackendResponse::Error { message } => { - // Display error in status area - let _ = ui_clone - .send_event(code_assistant_core::ui::UiEvent::DisplayError { - message, - }) - .await; - } - BackendResponse::SessionCreated { .. } => {} - BackendResponse::SessionDeleted { .. } => {} - BackendResponse::ModelSwitched { - session_id: _, - model_name, - warning, - allowed_models: _, - } => { - // Update current model in app state - let mut state = app_state_clone.lock().await; - state.update_current_model(Some(model_name.clone())); - let info = match warning { - Some(w) => format!("Switched to model: {model_name} ({w})"), - None => format!("Switched to model: {model_name}"), - }; - state.set_info_message(Some(info)); - } - - BackendResponse::SandboxPolicyChanged { - session_id: _, - policy, - } => { - let mut state = app_state_clone.lock().await; - state.update_sandbox_policy(Some(policy.clone())); - state.set_info_message(Some(format!( - "Sandbox mode set to {:?}", - policy - ))); - } - - BackendResponse::SubAgentCancelled { - session_id: _, - tool_id: _, - } => { - // Sub-agent cancellation handled; the sub-agent will - // update its tool output via the normal mechanism - } - - BackendResponse::MessageEditReady { .. } - | BackendResponse::BranchSwitched { .. } - | BackendResponse::MessageEditCancelled { .. } => { - // Session branching not supported in terminal UI - } - - BackendResponse::BranchesAndWorktreesListed { .. } - | BackendResponse::WorktreeSwitched { .. } - | BackendResponse::WorktreeCreated { .. } => { - // Worktree management not supported in terminal UI - } - - BackendResponse::ProjectAdded { .. } - | BackendResponse::ProjectPersisted { .. } - | BackendResponse::ProjectAlreadyExists { .. } => { - // Project management not supported in terminal UI - } - } - } - }); - } - // Flush stdout to ensure instructions are displayed std::io::Write::flush(&mut std::io::stdout())?; @@ -999,8 +1029,6 @@ impl TerminalTuiApp { // Bind renderer to UI for message printing and input redraws terminal_ui.set_renderer_async(renderer.clone()).await; - // Create redraw notification channel - let (redraw_tx, redraw_rx) = tokio::sync::watch::channel::<()>(()); terminal_ui.set_redraw_sender(redraw_tx.clone()); // Display welcome banner with project info @@ -1030,12 +1058,7 @@ impl TerminalTuiApp { // Send initial task if provided if let Some(task) = &config.task { - let _ = backend_event_tx.try_send(BackendEvent::SendUserMessage { - session_id: session_id.clone(), - message: task.clone(), - attachments: Vec::new(), - branch_parent_id: None, - }); + actions.send_user_message(session_id.clone(), task.clone(), Vec::new()); } // Start main event loop in a separate task @@ -1044,7 +1067,7 @@ impl TerminalTuiApp { renderer.clone(), app_state, terminal_ui.cancel_flag.clone(), - backend_event_tx, + actions, tui, redraw_rx, )); diff --git a/crates/ui_terminal/src/slash_popup/command_list.rs b/crates/ui_terminal/src/slash_popup/command_list.rs index ff6542f7..d9941129 100644 --- a/crates/ui_terminal/src/slash_popup/command_list.rs +++ b/crates/ui_terminal/src/slash_popup/command_list.rs @@ -3,7 +3,7 @@ use crate::commands::{all_commands, CommandResult}; use crate::slash_popup::skill_picker::SkillPickerPopup; use crate::slash_popup::{PopupAction, PopupRow, SlashPopup}; -use code_assistant_core::backend::SkillCatalogEntry; +use code_assistant_core::session::service::SkillCatalogEntry; pub struct CommandListPopup { /// All rows the popup knows about, before filtering. diff --git a/crates/ui_terminal/src/slash_popup/skill_picker.rs b/crates/ui_terminal/src/slash_popup/skill_picker.rs index f8ad36cd..8f68125d 100644 --- a/crates/ui_terminal/src/slash_popup/skill_picker.rs +++ b/crates/ui_terminal/src/slash_popup/skill_picker.rs @@ -3,12 +3,12 @@ //! Pushed by [`super::command_list::CommandListPopup`] when `/skill` is //! activated. Unlike the model picker (which reads global config), the skills //! are session-scoped and supplied from [`crate::state::AppState::skills`], -//! which is populated from a [`code_assistant_core::backend::BackendEvent::ListSkills`] +//! which is populated from a `SessionService::list_skills` //! request. use crate::commands::CommandResult; use crate::slash_popup::{PopupAction, PopupRow, SlashPopup}; -use code_assistant_core::backend::SkillCatalogEntry; +use code_assistant_core::session::service::SkillCatalogEntry; pub struct SkillPickerPopup { /// All skill entries (parallel to `all_rows`), used to dispatch on activate. diff --git a/crates/ui_terminal/src/state.rs b/crates/ui_terminal/src/state.rs index 49359cd2..c81c5189 100644 --- a/crates/ui_terminal/src/state.rs +++ b/crates/ui_terminal/src/state.rs @@ -1,7 +1,7 @@ use crate::slash_popup::PopupStack; -use code_assistant_core::backend::SkillCatalogEntry; use code_assistant_core::persistence::ChatMetadata; use code_assistant_core::session::instance::SessionActivityState; +use code_assistant_core::session::service::SkillCatalogEntry; use code_assistant_core::types::PlanState; use sandbox::SandboxPolicy; use std::collections::HashMap; diff --git a/crates/ui_terminal/src/ui.rs b/crates/ui_terminal/src/ui.rs index f4a7062e..7805069b 100644 --- a/crates/ui_terminal/src/ui.rs +++ b/crates/ui_terminal/src/ui.rs @@ -1,6 +1,5 @@ use async_trait::async_trait; use code_assistant_core::ui::{DisplayFragment, UIError, UiEvent, UserInterface}; -use std::any::Any; use std::sync::{ atomic::{AtomicBool, Ordering}, Arc, @@ -584,8 +583,4 @@ impl UserInterface for TerminalUI { } }); } - - fn as_any(&self) -> &dyn Any { - self - } }