From 2a8fa0d3bc010f334a14bdc7ed5d4511803ec514 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Tue, 7 Apr 2026 19:06:53 -0700 Subject: [PATCH] Wire up ACP session lifecycle: create, load, list, and stream (#115) Replace the old chat/sessions commands and frontend stores with direct ACP integration. Sessions are created lazily, loaded via list_sessions and load_session, and messages stream through the existing ACP event pipeline. - Add acp::create_session, list_sessions, load_session Tauri commands - Simplify chatSessionStore to drive state from ACP responses - Add ChatLoadingSkeleton for session loading transitions - Add shared/api/acp.ts frontend bindings - Remove legacy chat.rs, sessions.rs, catchup.rs, and store.rs - Support GOOSE_LOCAL_BINARY env var for local dev - Add tests for session loading and draft persistence Co-authored-by: morgmart <98432065+morgmart@users.noreply.github.com> --- ui/goose2/justfile | 48 +-- ui/goose2/scripts/check-file-sizes.mjs | 5 + ui/goose2/src-tauri/src/commands/acp.rs | 67 ++-- ui/goose2/src-tauri/src/commands/chat.rs | 30 -- ui/goose2/src-tauri/src/commands/mod.rs | 2 - ui/goose2/src-tauri/src/commands/sessions.rs | 66 ---- ui/goose2/src-tauri/src/lib.rs | 16 +- .../src-tauri/src/services/acp/catchup.rs | 213 ------------ .../src-tauri/src/services/acp/manager.rs | 87 ++++- .../src/services/acp/manager/dispatcher.rs | 190 ++++++++++- .../src/services/acp/manager/session_ops.rs | 92 ++++- ui/goose2/src-tauri/src/services/acp/mod.rs | 127 +------ .../src-tauri/src/services/acp/payloads.rs | 16 +- ui/goose2/src-tauri/src/services/acp/store.rs | 190 ----------- .../src-tauri/src/services/acp/writer.rs | 196 +---------- ui/goose2/src-tauri/src/services/mod.rs | 1 - ui/goose2/src-tauri/src/services/sessions.rs | 317 ------------------ ui/goose2/src-tauri/src/types/messages.rs | 1 - ui/goose2/src-tauri/src/types/mod.rs | 2 + ui/goose2/src/app/AppShell.tsx | 49 +-- ui/goose2/src/app/hooks/useAppStartup.ts | 5 + .../src/features/chat/hooks/useAcpStream.ts | 73 +++- .../stores/__tests__/chatSessionStore.test.ts | 266 +++++++++++++++ .../chat/stores/__tests__/chatStore.test.ts | 57 ++++ .../features/chat/stores/chatSessionStore.ts | 154 +++------ .../src/features/chat/stores/chatStore.ts | 19 ++ .../features/chat/ui/ChatLoadingSkeleton.tsx | 75 +++++ ui/goose2/src/features/chat/ui/ChatView.tsx | 35 +- .../ui/__tests__/ChatLoadingSkeleton.test.tsx | 26 ++ .../__tests__/useSessionAutoTitle.test.ts | 136 -------- .../sessions/hooks/useSessionAutoTitle.ts | 59 ---- .../sessions/ui/SessionHistoryView.tsx | 31 +- .../features/settings/ui/SettingsModal.tsx | 10 +- ui/goose2/src/shared/api/acp.ts | 30 ++ ui/goose2/src/shared/api/chat.ts | 61 ---- ui/goose2/src/shared/api/index.ts | 1 - .../shared/types/__tests__/messages.test.ts | 7 +- ui/goose2/src/shared/types/messages.ts | 6 +- 38 files changed, 1082 insertions(+), 1684 deletions(-) delete mode 100644 ui/goose2/src-tauri/src/commands/chat.rs delete mode 100644 ui/goose2/src-tauri/src/commands/sessions.rs delete mode 100644 ui/goose2/src-tauri/src/services/acp/catchup.rs delete mode 100644 ui/goose2/src-tauri/src/services/acp/store.rs delete mode 100644 ui/goose2/src-tauri/src/services/sessions.rs create mode 100644 ui/goose2/src/features/chat/stores/__tests__/chatSessionStore.test.ts create mode 100644 ui/goose2/src/features/chat/ui/ChatLoadingSkeleton.tsx create mode 100644 ui/goose2/src/features/chat/ui/__tests__/ChatLoadingSkeleton.test.tsx delete mode 100644 ui/goose2/src/features/sessions/hooks/__tests__/useSessionAutoTitle.test.ts delete mode 100644 ui/goose2/src/features/sessions/hooks/useSessionAutoTitle.ts delete mode 100644 ui/goose2/src/shared/api/chat.ts diff --git a/ui/goose2/justfile b/ui/goose2/justfile index 0dbffc5587..86ce081dd0 100644 --- a/ui/goose2/justfile +++ b/ui/goose2/justfile @@ -79,17 +79,21 @@ dev: #!/usr/bin/env bash set -euo pipefail - LOCAL_GOOSE_BIN="$(./scripts/ensure-local-goose.sh --check-bin)" || { - rc=$? - if [[ $rc -eq 2 ]]; then - echo "❌ Local goose binary is not ready. Run 'just setup' first." >&2 - exit 1 + if [[ -n "${GOOSE_BIN:-}" ]]; then + echo "Using explicitly set GOOSE_BIN: ${GOOSE_BIN}" + else + LOCAL_GOOSE_BIN="$(./scripts/ensure-local-goose.sh --check-bin)" || { + rc=$? + if [[ $rc -eq 2 ]]; then + echo "❌ Local goose binary is not ready. Run 'just setup' first." >&2 + exit 1 + fi + exit $rc + } + if [[ -n "${LOCAL_GOOSE_BIN}" ]]; then + export GOOSE_BIN="${LOCAL_GOOSE_BIN}" + echo "Using local goose binary: ${GOOSE_BIN}" fi - exit $rc - } - if [[ -n "${LOCAL_GOOSE_BIN}" ]]; then - export GOOSE_BIN="${LOCAL_GOOSE_BIN}" - echo "Using local goose binary: ${GOOSE_BIN}" fi # Derive a stable port from the working directory so the same worktree @@ -125,17 +129,21 @@ dev-debug: #!/usr/bin/env bash set -euo pipefail - LOCAL_GOOSE_BIN="$(./scripts/ensure-local-goose.sh --check-bin)" || { - rc=$? - if [[ $rc -eq 2 ]]; then - echo "❌ Local goose binary is not ready. Run 'just setup' first." >&2 - exit 1 + if [[ -n "${GOOSE_BIN:-}" ]]; then + echo "Using explicitly set GOOSE_BIN: ${GOOSE_BIN}" + else + LOCAL_GOOSE_BIN="$(./scripts/ensure-local-goose.sh --check-bin)" || { + rc=$? + if [[ $rc -eq 2 ]]; then + echo "❌ Local goose binary is not ready. Run 'just setup' first." >&2 + exit 1 + fi + exit $rc + } + if [[ -n "${LOCAL_GOOSE_BIN}" ]]; then + export GOOSE_BIN="${LOCAL_GOOSE_BIN}" + echo "Using local goose binary: ${GOOSE_BIN}" fi - exit $rc - } - if [[ -n "${LOCAL_GOOSE_BIN}" ]]; then - export GOOSE_BIN="${LOCAL_GOOSE_BIN}" - echo "Using local goose binary: ${GOOSE_BIN}" fi VITE_PORT=$(python3 -c "import hashlib,os; h=int(hashlib.sha256(os.getcwd().encode()).hexdigest(),16); print(10000 + h % 55000)") diff --git a/ui/goose2/scripts/check-file-sizes.mjs b/ui/goose2/scripts/check-file-sizes.mjs index ecb646801c..a80012b342 100644 --- a/ui/goose2/scripts/check-file-sizes.mjs +++ b/ui/goose2/scripts/check-file-sizes.mjs @@ -24,6 +24,11 @@ const EXCEPTIONS = { limit: 575, justification: "Added search-as-you-type filtering for sidebar sessions.", }, + "src-tauri/src/services/acp/manager.rs": { + limit: 520, + justification: + "ACP manager command dispatch loop — each new command variant adds ~10 lines of boilerplate.", + }, }; // Directories excluded from size checks (imported library code) diff --git a/ui/goose2/src-tauri/src/commands/acp.rs b/ui/goose2/src-tauri/src/commands/acp.rs index e7d14a9db1..daa3ab8c57 100644 --- a/ui/goose2/src-tauri/src/commands/acp.rs +++ b/ui/goose2/src-tauri/src/commands/acp.rs @@ -4,11 +4,8 @@ use std::sync::Arc; use tauri::{AppHandle, State}; use crate::services::acp::{ - make_composite_key, AcpRunningSession, AcpService, AcpSessionRegistry, GooseAcpManager, -}; -use crate::services::sessions::SessionStore; -use crate::types::messages::{ - MessageCompletionStatus, MessageContent, MessageMetadata, ToolCallStatus, + make_composite_key, AcpRunningSession, AcpService, AcpSessionInfo, AcpSessionRegistry, + GooseAcpManager, }; const DEPRECATED_PROVIDER_IDS: &[&str] = &["claude-code", "codex", "gemini-cli"]; @@ -109,7 +106,6 @@ pub async fn discover_acp_providers( pub async fn acp_send_message( app_handle: AppHandle, registry: State<'_, Arc>, - session_store: State<'_, Arc>, session_id: String, provider_id: String, prompt: String, @@ -126,7 +122,6 @@ pub async fn acp_send_message( AcpService::send_prompt( app_handle, Arc::clone(®istry), - Arc::clone(&session_store), session_id, provider_id, prompt, @@ -142,7 +137,6 @@ pub async fn acp_send_message( #[tauri::command] pub async fn acp_prepare_session( app_handle: AppHandle, - session_store: State<'_, Arc>, session_id: String, provider_id: String, working_dir: Option, @@ -152,15 +146,36 @@ pub async fn acp_prepare_session( .map_err(|error| format!("Failed to determine current working directory: {error}"))?; let working_dir = resolve_working_dir(working_dir, ¤t_dir)?; - AcpService::prepare_session( - app_handle, - session_id, - provider_id, - working_dir, - persona_id, - Arc::clone(&session_store), - ) - .await + AcpService::prepare_session(app_handle, session_id, provider_id, working_dir, persona_id).await +} + +/// List all sessions known to the goose binary. +#[tauri::command] +pub async fn acp_list_sessions(app_handle: AppHandle) -> Result, String> { + let manager = GooseAcpManager::start(app_handle).await?; + manager.list_sessions().await +} + +/// Load an existing session, replaying its messages as Tauri events. +/// +/// The goose binary sends `SessionNotification` events for each message in +/// the session history. The frontend's `useAcpStream` hook picks these up +/// the same way it handles live streaming. +#[tauri::command] +pub async fn acp_load_session( + app_handle: AppHandle, + session_id: String, + goose_session_id: String, + working_dir: Option, +) -> Result<(), String> { + let current_dir = std::env::current_dir() + .map_err(|error| format!("Failed to determine current working directory: {error}"))?; + let working_dir = resolve_working_dir(working_dir, ¤t_dir)?; + + let manager = GooseAcpManager::start(app_handle).await?; + manager + .load_session(session_id, goose_session_id, working_dir) + .await } #[cfg(test)] @@ -228,30 +243,14 @@ mod tests { pub async fn acp_cancel_session( app_handle: AppHandle, registry: State<'_, Arc>, - session_store: State<'_, Arc>, session_id: String, persona_id: Option, ) -> Result { let key = make_composite_key(&session_id, persona_id.as_deref()); - let assistant_message_id = registry.cancel(&key); + let _assistant_message_id = registry.cancel(&key); let manager = GooseAcpManager::start(app_handle).await?; let was_cancelled = manager.cancel_session(key).await?; - if let Some(message_id) = assistant_message_id.as_deref() { - let _ = session_store.update_message(&session_id, message_id, |message| { - for block in &mut message.content { - if let MessageContent::ToolRequest { status, .. } = block { - *status = ToolCallStatus::Stopped; - } - } - - let metadata = message - .metadata - .get_or_insert_with(MessageMetadata::default); - metadata.completion_status = Some(MessageCompletionStatus::Stopped); - }); - } - Ok(was_cancelled) } diff --git a/ui/goose2/src-tauri/src/commands/chat.rs b/ui/goose2/src-tauri/src/commands/chat.rs deleted file mode 100644 index b7dc50de64..0000000000 --- a/ui/goose2/src-tauri/src/commands/chat.rs +++ /dev/null @@ -1,30 +0,0 @@ -use std::sync::Arc; - -use crate::services::sessions::SessionStore; -use crate::types::messages::*; -use tauri::State; - -#[tauri::command] -pub async fn chat_send_message( - session_store: State<'_, Arc>, - session_id: String, - message: Message, -) -> Result { - // 1. Store the user message - session_store.add_message(&session_id, message.clone())?; - - // 2. TODO: Connect to goosed via HTTP/SSE for real inference - // For now, return a mock assistant response - let response = Message { - id: uuid::Uuid::new_v4().to_string(), - role: MessageRole::Assistant, - created: chrono::Utc::now().timestamp_millis(), - content: vec![MessageContent::Text { - text: "I'm Goose, your AI coding assistant. The chat backend is being set up — real inference coming soon! For now, I can confirm the message pipeline is working end-to-end.".to_string(), - }], - metadata: None, - }; - - session_store.add_message(&session_id, response.clone())?; - Ok(response) -} diff --git a/ui/goose2/src-tauri/src/commands/mod.rs b/ui/goose2/src-tauri/src/commands/mod.rs index f2be844d89..ea5550a143 100644 --- a/ui/goose2/src-tauri/src/commands/mod.rs +++ b/ui/goose2/src-tauri/src/commands/mod.rs @@ -1,9 +1,7 @@ pub mod acp; pub mod agents; -pub mod chat; pub mod doctor; pub mod git; pub mod projects; -pub mod sessions; pub mod skills; pub mod system; diff --git a/ui/goose2/src-tauri/src/commands/sessions.rs b/ui/goose2/src-tauri/src/commands/sessions.rs deleted file mode 100644 index 4a1f2995bc..0000000000 --- a/ui/goose2/src-tauri/src/commands/sessions.rs +++ /dev/null @@ -1,66 +0,0 @@ -use std::sync::Arc; - -use crate::services::sessions::SessionStore; -use crate::types::agents::{Session, SessionUpdate}; -use crate::types::messages::Message; -use tauri::State; - -#[tauri::command] -pub fn create_session( - store: State<'_, Arc>, - agent_id: Option, - project_id: Option, -) -> Session { - store.create_session(agent_id, project_id) -} - -#[tauri::command] -pub fn list_sessions(store: State<'_, Arc>) -> Vec { - store.list_sessions() -} - -#[tauri::command] -pub fn get_session_messages( - store: State<'_, Arc>, - session_id: String, -) -> Vec { - store.get_messages(&session_id) -} - -#[tauri::command] -pub fn update_session( - store: State<'_, Arc>, - session_id: String, - update: SessionUpdate, -) -> Result<(), String> { - store.update_session(&session_id, update) -} - -#[tauri::command] -pub fn delete_session( - store: State<'_, Arc>, - session_id: String, -) -> Result<(), String> { - store.delete_session(&session_id) -} - -#[tauri::command] -pub fn list_archived_sessions(store: State<'_, Arc>) -> Vec { - store.list_archived_sessions() -} - -#[tauri::command] -pub fn archive_session( - store: State<'_, Arc>, - session_id: String, -) -> Result<(), String> { - store.archive_session(&session_id) -} - -#[tauri::command] -pub fn unarchive_session( - store: State<'_, Arc>, - session_id: String, -) -> Result<(), String> { - store.unarchive_session(&session_id) -} diff --git a/ui/goose2/src-tauri/src/lib.rs b/ui/goose2/src-tauri/src/lib.rs index bfe5216032..13d04762f1 100644 --- a/ui/goose2/src-tauri/src/lib.rs +++ b/ui/goose2/src-tauri/src/lib.rs @@ -6,14 +6,10 @@ use std::sync::Arc; use services::acp::AcpSessionRegistry; use services::personas::PersonaStore; -use services::sessions::SessionStore; use tauri_plugin_window_state::StateFlags; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - // Clean up stale ACP session files older than 24 hours. - services::acp::TauriStore::cleanup_stale_sessions(std::time::Duration::from_secs(24 * 60 * 60)); - let acp_registry = Arc::new(AcpSessionRegistry::new()); let acp_registry_for_exit = Arc::clone(&acp_registry); @@ -34,7 +30,6 @@ pub fn run() { .build(), ) .manage(PersonaStore::new()) - .manage(Arc::new(SessionStore::new())) .manage(acp_registry) .invoke_handler(tauri::generate_handler![ commands::agents::list_personas, @@ -47,19 +42,12 @@ pub fn run() { commands::agents::save_persona_avatar, commands::agents::save_persona_avatar_bytes, commands::agents::get_avatars_dir, - commands::sessions::create_session, - commands::sessions::list_sessions, - commands::sessions::get_session_messages, - commands::sessions::update_session, - commands::sessions::delete_session, - commands::sessions::list_archived_sessions, - commands::sessions::archive_session, - commands::sessions::unarchive_session, - commands::chat::chat_send_message, commands::acp::discover_acp_providers, commands::acp::acp_prepare_session, commands::acp::acp_send_message, commands::acp::acp_cancel_session, + commands::acp::acp_list_sessions, + commands::acp::acp_load_session, commands::acp::acp_list_running, commands::acp::acp_cancel_all, commands::skills::create_skill, diff --git a/ui/goose2/src-tauri/src/services/acp/catchup.rs b/ui/goose2/src-tauri/src/services/acp/catchup.rs deleted file mode 100644 index 308170f81c..0000000000 --- a/ui/goose2/src-tauri/src/services/acp/catchup.rs +++ /dev/null @@ -1,213 +0,0 @@ -use crate::types::messages::{Message, MessageContent, MessageRole}; - -/// Build catch-up context for a persona from intervening messages. -/// -/// When the user sends a message to persona X and there have been -/// intervening messages to/from other personas since persona X last -/// responded, this function produces a context block so persona X -/// stays informed. -/// -/// Returns `None` if there are no intervening messages to summarize. -pub fn build_catchup_context( - messages: &[Message], - persona_id: &str, - current_message_id: &str, -) -> Option { - // Algorithm: - // 1. Find the index of the last assistant message FROM this persona - // 2. Collect all messages after that point, excluding: - // - The current user prompt being sent now - // - Messages from this persona itself - // 3. Format as catch-up text - // 4. Return None if no intervening messages - - if messages.is_empty() { - return None; - } - - // Find last assistant message from this persona - let last_own_idx = messages.iter().rposition(|m| { - matches!(m.role, MessageRole::Assistant) - && (m - .metadata - .as_ref() - .and_then(|meta| meta.persona_id.as_deref()) - == Some(persona_id)) - }); - - // Start collecting from after the last own message (or from beginning) - let start_idx = match last_own_idx { - Some(idx) => idx + 1, - None => 0, - }; - - let mut lines = Vec::new(); - - for msg in &messages[start_idx..] { - if msg.id == current_message_id { - continue; - } - - if matches!(msg.role, MessageRole::User) { - let target_persona_id = msg - .metadata - .as_ref() - .and_then(|meta| meta.target_persona_id.as_deref()); - if target_persona_id == Some(persona_id) { - continue; - } - } - - // Skip assistant messages from this persona itself - if matches!(msg.role, MessageRole::Assistant) { - let msg_persona_id = msg - .metadata - .as_ref() - .and_then(|meta| meta.persona_id.as_deref()); - if msg_persona_id == Some(persona_id) { - continue; - } - } - let text = extract_text(msg); - if text.is_empty() { - continue; - } - - let truncated = truncate_text(&text, 200); - - match msg.role { - MessageRole::User => { - let target_name = msg - .metadata - .as_ref() - .and_then(|meta| meta.target_persona_name.as_deref()) - .unwrap_or("Unknown"); - lines.push(format!("- User \u{2192} {}: {}", target_name, truncated)); - } - MessageRole::Assistant => { - let name = msg - .metadata - .as_ref() - .and_then(|meta| meta.persona_name.as_deref().or(meta.persona_id.as_deref())) - .unwrap_or("Unknown"); - lines.push(format!("- {}: {}", name, truncated)); - } - MessageRole::System => { - lines.push(format!("- System: {}", truncated)); - } - } - } - - if lines.is_empty() { - return None; - } - - Some(format!( - "[Context from other participants since your last message]\n{}", - lines.join("\n") - )) -} - -/// Extract concatenated text content from a message. -fn extract_text(msg: &Message) -> String { - msg.content - .iter() - .filter_map(|c| match c { - MessageContent::Text { text } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join(" ") -} - -/// Truncate text to `max_chars` characters, appending "..." if truncated. -fn truncate_text(text: &str, max_chars: usize) -> String { - match text.char_indices().nth(max_chars) { - Some((byte_idx, _)) => format!("{}...", &text[..byte_idx]), - None => text.to_string(), - } -} - -#[cfg(test)] -mod tests { - use super::build_catchup_context; - use crate::types::messages::{Message, MessageContent, MessageMetadata, MessageRole}; - - fn text_message( - id: &str, - role: MessageRole, - text: &str, - metadata: Option, - ) -> Message { - Message { - id: id.to_string(), - role, - created: 0, - content: vec![MessageContent::Text { - text: text.to_string(), - }], - metadata, - } - } - - #[test] - fn skips_user_messages_already_targeted_at_current_persona() { - let messages = vec![ - text_message( - "assistant-a", - MessageRole::Assistant, - "Earlier reply", - Some(MessageMetadata { - persona_id: Some("persona-a".to_string()), - persona_name: Some("Persona A".to_string()), - ..Default::default() - }), - ), - text_message( - "user-to-a", - MessageRole::User, - "Please continue", - Some(MessageMetadata { - target_persona_id: Some("persona-a".to_string()), - target_persona_name: Some("Persona A".to_string()), - ..Default::default() - }), - ), - text_message( - "assistant-b", - MessageRole::Assistant, - "Something happened elsewhere", - Some(MessageMetadata { - persona_id: Some("persona-b".to_string()), - persona_name: Some("Persona B".to_string()), - ..Default::default() - }), - ), - ]; - - let context = build_catchup_context(&messages, "persona-a", "current-user-message") - .expect("expected catch-up context"); - - assert!(!context.contains("Please continue")); - assert!(context.contains("Persona B: Something happened elsewhere")); - } - - #[test] - fn keeps_user_messages_for_other_personas_in_catchup() { - let messages = vec![text_message( - "user-to-b", - MessageRole::User, - "Can Persona B take this one?", - Some(MessageMetadata { - target_persona_id: Some("persona-b".to_string()), - target_persona_name: Some("Persona B".to_string()), - ..Default::default() - }), - )]; - - let context = build_catchup_context(&messages, "persona-a", "current-user-message") - .expect("expected catch-up context"); - - assert!(context.contains("User → Persona B: Can Persona B take this one?")); - } -} diff --git a/ui/goose2/src-tauri/src/services/acp/manager.rs b/ui/goose2/src-tauri/src/services/acp/manager.rs index c332e5bfc4..b8fa4a7fc9 100644 --- a/ui/goose2/src-tauri/src/services/acp/manager.rs +++ b/ui/goose2/src-tauri/src/services/acp/manager.rs @@ -5,7 +5,7 @@ use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::Arc; -use acp_client::{MessageWriter, Store}; +use acp_client::MessageWriter; use agent_client_protocol::{ Agent, ClientSideConnection, ExtRequest, Implementation, InitializeRequest, ProtocolVersion, }; @@ -20,22 +20,31 @@ use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; use super::goose_serve::{GooseServeProcess, WS_BRIDGE_BUFFER_BYTES}; use dispatcher::{SessionEventDispatcher, SessionRoute}; +pub use session_ops::AcpSessionInfo; use session_ops::{ - cancel_session_inner, prepare_session_inner, send_prompt_inner, ManagerState, - PrepareSessionInput, + cancel_session_inner, list_sessions_inner, load_session_inner, prepare_session_inner, + send_prompt_inner, ManagerState, PrepareSessionInput, }; enum ManagerCommand { ListProviders { response: oneshot::Sender, String>>, }, + ListSessions { + response: oneshot::Sender, String>>, + }, + LoadSession { + local_session_id: String, + goose_session_id: String, + working_dir: PathBuf, + response: oneshot::Sender>, + }, PrepareSession { composite_key: String, local_session_id: String, provider_id: String, working_dir: PathBuf, existing_agent_session_id: Option, - store: Arc, response: oneshot::Sender>, }, SendPrompt { @@ -44,7 +53,6 @@ enum ManagerCommand { provider_id: String, working_dir: PathBuf, existing_agent_session_id: Option, - store: Arc, writer: Arc, prompt: String, images: Vec<(String, String)>, @@ -98,6 +106,38 @@ impl GooseAcpManager { .map(Arc::clone) } + pub async fn list_sessions(&self) -> Result, String> { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(ManagerCommand::ListSessions { + response: response_tx, + }) + .map_err(|_| "Goose ACP manager is unavailable".to_string())?; + response_rx + .await + .map_err(|_| "Goose ACP manager dropped list sessions request".to_string())? + } + + pub async fn load_session( + &self, + local_session_id: String, + goose_session_id: String, + working_dir: PathBuf, + ) -> Result<(), String> { + let (response_tx, response_rx) = oneshot::channel(); + self.command_tx + .send(ManagerCommand::LoadSession { + local_session_id, + goose_session_id, + working_dir, + response: response_tx, + }) + .map_err(|_| "Goose ACP manager is unavailable".to_string())?; + response_rx + .await + .map_err(|_| "Goose ACP manager dropped load session request".to_string())? + } + pub async fn prepare_session( &self, composite_key: String, @@ -105,7 +145,6 @@ impl GooseAcpManager { provider_id: String, working_dir: PathBuf, existing_agent_session_id: Option, - store: Arc, ) -> Result<(), String> { let (response_tx, response_rx) = oneshot::channel(); self.command_tx @@ -115,7 +154,6 @@ impl GooseAcpManager { provider_id, working_dir, existing_agent_session_id, - store, response: response_tx, }) .map_err(|_| "Goose ACP manager is unavailable".to_string())?; @@ -144,7 +182,6 @@ impl GooseAcpManager { provider_id: String, working_dir: PathBuf, existing_agent_session_id: Option, - store: Arc, writer: Arc, prompt: String, images: Vec<(String, String)>, @@ -157,7 +194,6 @@ impl GooseAcpManager { provider_id, working_dir, existing_agent_session_id, - store, writer, prompt, images, @@ -361,13 +397,41 @@ fn run_manager_thread( let _ = response.send(result); }); } + ManagerCommand::ListSessions { response } => { + let connection = Arc::clone(&connection); + tokio::task::spawn_local(async move { + let result = list_sessions_inner(&connection).await; + let _ = response.send(result); + }); + } + ManagerCommand::LoadSession { + local_session_id, + goose_session_id, + working_dir, + response, + } => { + let connection = Arc::clone(&connection); + let dispatcher = dispatcher.clone(); + let state = Arc::clone(&state); + tokio::task::spawn_local(async move { + let result = load_session_inner( + &connection, + &dispatcher, + &state, + &local_session_id, + &goose_session_id, + working_dir, + ) + .await; + let _ = response.send(result); + }); + } ManagerCommand::PrepareSession { composite_key, local_session_id, provider_id, working_dir, existing_agent_session_id, - store, response, } => { let connection = Arc::clone(&connection); @@ -384,7 +448,6 @@ fn run_manager_thread( provider_id, working_dir, existing_agent_session_id, - store, }, ) .await @@ -398,7 +461,6 @@ fn run_manager_thread( provider_id, working_dir, existing_agent_session_id, - store, writer, prompt, images, @@ -417,7 +479,6 @@ fn run_manager_thread( provider_id, working_dir, existing_agent_session_id, - store, writer, prompt, images, diff --git a/ui/goose2/src-tauri/src/services/acp/manager/dispatcher.rs b/ui/goose2/src-tauri/src/services/acp/manager/dispatcher.rs index bc62ec8e2f..aa3c6cc28f 100644 --- a/ui/goose2/src-tauri/src/services/acp/manager/dispatcher.rs +++ b/ui/goose2/src-tauri/src/services/acp/manager/dispatcher.rs @@ -13,13 +13,18 @@ use async_trait::async_trait; use tauri::Emitter; use tokio::sync::Mutex; -use crate::services::acp::payloads::{ModelStatePayload, SessionInfoPayload}; +use crate::services::acp::payloads::{ + DonePayload, MessageCreatedPayload, ModelStatePayload, SessionInfoPayload, TextPayload, + ToolCallPayload, ToolResultPayload, ToolTitlePayload, +}; #[derive(Clone)] pub(super) struct SessionRoute { pub(super) local_session_id: String, pub(super) writer: Option>, pub(super) canceled: bool, + /// Tracks the current assistant message ID during replay (no writer). + pub(super) replay_message_id: Option, } pub(super) struct SessionEventDispatcher { @@ -44,6 +49,7 @@ impl SessionEventDispatcher { local_session_id: local_session_id.to_string(), writer: None, canceled: false, + replay_message_id: None, }); } @@ -60,6 +66,7 @@ impl SessionEventDispatcher { local_session_id: local_session_id.to_string(), writer: Some(writer), canceled: false, + replay_message_id: None, }, ); } @@ -90,6 +97,24 @@ impl SessionEventDispatcher { .is_some_and(|route| route.canceled) } + /// Finalize any in-progress replay message and clear the replay state. + /// Called after `load_session` completes to mark the last replayed + /// assistant message as done. + pub(super) async fn finalize_replay(&self, goose_session_id: &str) { + let mut routes = self.routes.lock().await; + if let Some(route) = routes.get_mut(goose_session_id) { + if let Some(message_id) = route.replay_message_id.take() { + let _ = self.app_handle.emit( + "acp:done", + DonePayload { + session_id: route.local_session_id.clone(), + message_id, + }, + ); + } + } + } + pub(super) fn emit_session_info(&self, local_session_id: &str, info: &SessionInfoUpdate) { let _ = self.app_handle.emit( "acp:session_info", @@ -233,30 +258,165 @@ impl Client for SessionEventDispatcher { _ => {} } - let Some(writer) = route.writer else { + // Live streaming path — writer is present + if let Some(writer) = route.writer { + match ¬ification.update { + SessionUpdate::AgentMessageChunk(chunk) => { + if let AcpContentBlock::Text(text) = &chunk.content { + writer.append_text(&text.text).await; + } + } + SessionUpdate::ToolCall(tool_call) => { + writer + .record_tool_call(&tool_call.tool_call_id.0, &tool_call.title) + .await; + } + SessionUpdate::ToolCallUpdate(update) => { + if let Some(title) = &update.fields.title { + writer + .update_tool_call_title(&update.tool_call_id.0, title) + .await; + } + if let Some(content) = &update.fields.content { + if let Some(result) = extract_content_preview(content) { + writer.record_tool_result(&result).await; + } + } + } + _ => {} + } return Ok(()); - }; + } + // Replay path — no writer, emit Tauri events directly. + // This handles messages replayed by load_session. + let local_session_id = route.local_session_id.clone(); match ¬ification.update { + SessionUpdate::UserMessageChunk(chunk) => { + if let AcpContentBlock::Text(text) = &chunk.content { + // Finalize any in-progress assistant message first + { + let mut routes = self.routes.lock().await; + if let Some(route) = routes.get_mut(&goose_session_id) { + if let Some(prev_msg_id) = route.replay_message_id.take() { + let _ = self.app_handle.emit( + "acp:done", + DonePayload { + session_id: local_session_id.clone(), + message_id: prev_msg_id, + }, + ); + } + } + } + + let message_id = uuid::Uuid::new_v4().to_string(); + let _ = self.app_handle.emit( + "acp:replay_user_message", + serde_json::json!({ + "sessionId": local_session_id, + "messageId": message_id, + "text": text.text, + }), + ); + } + } SessionUpdate::AgentMessageChunk(chunk) => { if let AcpContentBlock::Text(text) = &chunk.content { - writer.append_text(&text.text).await; + // Check if we already have a replay message in progress + let replay_msg_id = { + let routes = self.routes.lock().await; + routes + .get(&goose_session_id) + .and_then(|r| r.replay_message_id.clone()) + }; + + let message_id = if let Some(id) = replay_msg_id { + id + } else { + // Start a new assistant message + let new_id = uuid::Uuid::new_v4().to_string(); + let _ = self.app_handle.emit( + "acp:message_created", + MessageCreatedPayload { + session_id: local_session_id.clone(), + message_id: new_id.clone(), + persona_id: None, + persona_name: None, + }, + ); + let mut routes = self.routes.lock().await; + if let Some(route) = routes.get_mut(&goose_session_id) { + route.replay_message_id = Some(new_id.clone()); + } + new_id + }; + + let _ = self.app_handle.emit( + "acp:text", + TextPayload { + session_id: local_session_id, + message_id: message_id.clone(), + text: text.text.clone(), + }, + ); } } SessionUpdate::ToolCall(tool_call) => { - writer - .record_tool_call(&tool_call.tool_call_id.0, &tool_call.title) - .await; + let replay_msg_id = { + let routes = self.routes.lock().await; + routes + .get(&goose_session_id) + .and_then(|r| r.replay_message_id.clone()) + }; + + if let Some(message_id) = replay_msg_id { + let _ = self.app_handle.emit( + "acp:tool_call", + ToolCallPayload { + session_id: local_session_id, + message_id, + tool_call_id: tool_call.tool_call_id.0.to_string(), + title: tool_call.title.clone(), + }, + ); + } } SessionUpdate::ToolCallUpdate(update) => { - if let Some(title) = &update.fields.title { - writer - .update_tool_call_title(&update.tool_call_id.0, title) - .await; - } - if let Some(content) = &update.fields.content { - if let Some(result) = extract_content_preview(content) { - writer.record_tool_result(&result).await; + let replay_msg_id = { + let routes = self.routes.lock().await; + routes + .get(&goose_session_id) + .and_then(|r| r.replay_message_id.clone()) + }; + + if let Some(message_id) = replay_msg_id { + if let Some(title) = &update.fields.title { + let _ = self.app_handle.emit( + "acp:tool_title", + ToolTitlePayload { + session_id: local_session_id.clone(), + message_id: message_id.clone(), + tool_call_id: update.tool_call_id.0.to_string(), + title: title.clone(), + }, + ); + } + if let Some(content) = &update.fields.content { + // During replay, always emit a tool_result so the + // frontend can mark the tool request as completed. + // Fall back to a generic summary when the content + // type has no preview extractor. + let result = + extract_content_preview(content).unwrap_or_else(|| "Done".to_string()); + let _ = self.app_handle.emit( + "acp:tool_result", + ToolResultPayload { + session_id: local_session_id, + message_id, + content: result, + }, + ); } } } diff --git a/ui/goose2/src-tauri/src/services/acp/manager/session_ops.rs b/ui/goose2/src-tauri/src/services/acp/manager/session_ops.rs index 7d57da9e9a..e7b1455e2d 100644 --- a/ui/goose2/src-tauri/src/services/acp/manager/session_ops.rs +++ b/ui/goose2/src-tauri/src/services/acp/manager/session_ops.rs @@ -2,16 +2,25 @@ use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::Arc; -use acp_client::{MessageWriter, Store}; +use acp_client::MessageWriter; use agent_client_protocol::{ Agent, CancelNotification, ClientSideConnection, ContentBlock as AcpContentBlock, ImageContent, - LoadSessionRequest, NewSessionRequest, PromptRequest, SessionConfigKind, SessionConfigOption, - SessionConfigSelectOptions, SetSessionConfigOptionRequest, TextContent, + ListSessionsRequest, LoadSessionRequest, NewSessionRequest, PromptRequest, SessionConfigKind, + SessionConfigOption, SessionConfigSelectOptions, SetSessionConfigOptionRequest, TextContent, }; use tokio::sync::Mutex; use super::dispatcher::SessionEventDispatcher; +/// Lightweight session metadata returned by `list_sessions`. +#[derive(Clone, Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpSessionInfo { + pub session_id: String, + pub title: Option, + pub updated_at: Option, +} + #[derive(Clone)] pub(super) struct PreparedSession { goose_session_id: String, @@ -79,7 +88,6 @@ pub(super) struct PrepareSessionInput { pub(super) provider_id: String, pub(super) working_dir: PathBuf, pub(super) existing_agent_session_id: Option, - pub(super) store: Arc, } pub(super) async fn prepare_session_inner( @@ -94,7 +102,6 @@ pub(super) async fn prepare_session_inner( provider_id, working_dir, existing_agent_session_id, - store, } = input; let session_lock = { @@ -220,9 +227,6 @@ pub(super) async fn prepare_session_inner( .map_err(|error| format!("Failed to create Goose session: {error:?}"))?; let new_id = response.session_id.to_string(); - store - .set_agent_session_id(&local_session_id, &new_id) - .map_err(|error| format!("Failed to save Goose session ID: {error}"))?; dispatcher.bind_session(&new_id, &local_session_id).await; @@ -282,7 +286,6 @@ pub(super) async fn send_prompt_inner( provider_id: String, working_dir: PathBuf, existing_agent_session_id: Option, - store: Arc, writer: Arc, prompt: String, images: Vec<(String, String)>, @@ -297,7 +300,6 @@ pub(super) async fn send_prompt_inner( provider_id, working_dir, existing_agent_session_id, - store, }, ) .await @@ -349,6 +351,76 @@ pub(super) async fn send_prompt_inner( result } +/// List all sessions known to the goose binary. +pub(super) async fn list_sessions_inner( + connection: &Arc, +) -> Result, String> { + let response = connection + .list_sessions(ListSessionsRequest::default()) + .await + .map_err(|error| format!("Failed to list sessions via Goose ACP: {error:?}"))?; + + Ok(response + .sessions + .into_iter() + .map(|info| AcpSessionInfo { + session_id: info.session_id.to_string(), + title: info.title, + updated_at: info.updated_at, + }) + .collect()) +} + +/// Load an existing session from the goose binary. +/// +/// This binds the goose session ID to the local session ID in the dispatcher +/// so that replayed `SessionNotification` events are routed to the correct +/// frontend session. It also registers the session in the manager state so +/// that subsequent `send_prompt` calls can reuse the goose session. +pub(super) async fn load_session_inner( + connection: &Arc, + dispatcher: &Arc, + state: &Arc>, + local_session_id: &str, + goose_session_id: &str, + working_dir: PathBuf, +) -> Result<(), String> { + dispatcher + .bind_session(goose_session_id, local_session_id) + .await; + + let response = connection + .load_session(LoadSessionRequest::new( + goose_session_id.to_string(), + working_dir.clone(), + )) + .await + .map_err(|error| format!("Failed to load Goose session: {error:?}"))?; + + // Finalize any in-progress replay assistant message + dispatcher.finalize_replay(goose_session_id).await; + + if let Some(models) = &response.models { + dispatcher.emit_model_state(local_session_id, models); + } + if let Some(options) = &response.config_options { + dispatcher.emit_model_state_from_options(local_session_id, options); + } + + // Register the session so future prompts reuse this goose session + let mut guard = state.lock().await; + guard.sessions.insert( + local_session_id.to_string(), + PreparedSession { + goose_session_id: goose_session_id.to_string(), + provider_id: "goose".to_string(), // will be updated on next prepare + working_dir, + }, + ); + + Ok(()) +} + pub(super) async fn cancel_session_inner( connection: &Arc, dispatcher: &Arc, diff --git a/ui/goose2/src-tauri/src/services/acp/mod.rs b/ui/goose2/src-tauri/src/services/acp/mod.rs index 752c20d438..497b782c3b 100644 --- a/ui/goose2/src-tauri/src/services/acp/mod.rs +++ b/ui/goose2/src-tauri/src/services/acp/mod.rs @@ -1,26 +1,19 @@ -mod catchup; mod goose_serve; mod manager; mod payloads; mod registry; -mod store; mod writer; -pub use catchup::build_catchup_context; -pub use manager::GooseAcpManager; +pub use manager::{AcpSessionInfo, GooseAcpManager}; pub use registry::{AcpRunningSession, AcpSessionRegistry}; -pub use store::TauriStore; pub use writer::TauriMessageWriter; use std::path::PathBuf; use std::sync::Arc; -use acp_client::{MessageWriter, Store}; +use acp_client::MessageWriter; +use tauri::Emitter; -use crate::services::sessions::SessionStore; -use crate::types::messages::{ - MessageCompletionStatus, MessageContent, MessageMetadata, MessageRole, ToolCallStatus, -}; /// Build a composite registry key: `{session_id}__{persona_id}` when a /// persona is active, or plain `session_id` for backward compatibility. /// @@ -53,14 +46,8 @@ impl AcpService { provider_id: String, working_dir: PathBuf, persona_id: Option, - session_store: Arc, ) -> Result<(), String> { - session_store.ensure_session(&session_id, Some(provider_id.clone())); - let manager = GooseAcpManager::start(app_handle).await?; - let tauri_store = TauriStore::new(session_store, session_id.clone(), persona_id.clone()); - let existing_agent_session_id = tauri_store.get_agent_session_id(); - let store: Arc = Arc::new(tauri_store); manager .prepare_session( @@ -68,8 +55,7 @@ impl AcpService { session_id, provider_id, working_dir, - existing_agent_session_id, - store, + None, // no existing agent session ID — goose binary owns sessions ) .await } @@ -80,7 +66,6 @@ impl AcpService { pub async fn send_prompt( app_handle: tauri::AppHandle, registry: Arc, - session_store: Arc, session_id: String, provider_id: String, prompt: String, @@ -90,50 +75,12 @@ impl AcpService { persona_name: Option, images: Vec<(String, String)>, ) -> Result<(), String> { - // Ensure the session exists in the SessionStore (create if needed) - session_store.ensure_session(&session_id, Some(provider_id.clone())); - - // Save the user message to SessionStore, with persona metadata when targeted - let user_message_id = uuid::Uuid::new_v4().to_string(); - let user_message = crate::types::messages::Message { - id: user_message_id.clone(), - role: MessageRole::User, - created: chrono::Utc::now().timestamp_millis(), - content: vec![MessageContent::Text { - text: prompt.clone(), - }], - metadata: if persona_id.is_some() { - Some(crate::types::messages::MessageMetadata { - target_persona_id: persona_id.clone(), - target_persona_name: persona_name.clone(), - ..Default::default() - }) - } else { - None - }, - }; - if let Err(e) = session_store.add_message(&session_id, user_message) { - eprintln!( - "Failed to save user message for session {}: {}", - session_id, e - ); - } - - // Build catch-up context from intervening messages for this persona - let catchup_context = if let Some(ref pid) = persona_id { - let all_messages = session_store.get_messages(&session_id); - build_catchup_context(&all_messages, pid, &user_message_id) - } else { - None - }; - let registry_key = make_composite_key(&session_id, persona_id.as_deref()); let cancel_token = registry.register(®istry_key, &provider_id); let writer_impl = Arc::new(TauriMessageWriter::new( app_handle.clone(), session_id.clone(), - Arc::clone(&session_store), persona_id.clone(), persona_name.clone(), )); @@ -141,18 +88,13 @@ impl AcpService { ®istry_key, writer_impl.assistant_message_id().to_string(), ); - let tauri_store = - TauriStore::new(Arc::clone(&session_store), session_id.clone(), persona_id); - let agent_session_id = tauri_store.get_agent_session_id(); - let store: Arc = Arc::new(tauri_store); let writer: Arc = writer_impl.clone(); - // Build the effective prompt, including persona instructions and - // catch-up context when available. When there is no extra context we - // pass the raw prompt for backward compatibility. + // Build the effective prompt, including persona instructions when + // available. When there is no extra context we pass the raw prompt + // for backward compatibility. let has_system = system_prompt.as_ref().is_some_and(|s| !s.is_empty()); - let has_catchup = catchup_context.is_some(); - let effective_prompt = if has_system || has_catchup { + let effective_prompt = if has_system { let mut parts = Vec::new(); if let Some(ref sp) = system_prompt { if !sp.is_empty() { @@ -161,11 +103,6 @@ impl AcpService { )); } } - if let Some(ref ctx) = catchup_context { - parts.push(format!( - "\n{ctx}\n" - )); - } parts.push(format!("\n{prompt}\n")); parts.join("\n\n") } else { @@ -179,8 +116,7 @@ impl AcpService { session_id.clone(), provider_id.clone(), working_dir, - agent_session_id, - store, + None, // no existing agent session ID — goose binary owns sessions writer, effective_prompt, images, @@ -191,44 +127,15 @@ impl AcpService { drop(cancel_token); if let Err(ref error) = result { - let _ = session_store.update_message( - &session_id, - writer_impl.assistant_message_id(), - |message| { - for block in &mut message.content { - if let MessageContent::ToolRequest { status, .. } = block { - *status = ToolCallStatus::Error; - } - } - - let metadata = message - .metadata - .get_or_insert_with(MessageMetadata::default); - metadata.completion_status = Some(MessageCompletionStatus::Error); - }, - ); - - let error_message = crate::types::messages::Message { - id: uuid::Uuid::new_v4().to_string(), - role: MessageRole::System, - created: chrono::Utc::now().timestamp_millis(), - content: vec![MessageContent::SystemNotification { - notification_type: "error".to_string(), - text: error.clone(), - }], - metadata: Some(crate::types::messages::MessageMetadata { - user_visible: Some(true), - agent_visible: Some(false), - ..Default::default() + // Emit an error event so the frontend can display it + let _ = app_handle.emit( + "acp:error", + serde_json::json!({ + "sessionId": session_id, + "messageId": writer_impl.assistant_message_id(), + "error": error, }), - }; - - if let Err(save_error) = session_store.add_message(&session_id, error_message) { - eprintln!( - "Failed to save ACP error message for session {}: {}", - session_id, save_error - ); - } + ); } result diff --git a/ui/goose2/src-tauri/src/services/acp/payloads.rs b/ui/goose2/src-tauri/src/services/acp/payloads.rs index 9cf04509f0..79e44da127 100644 --- a/ui/goose2/src-tauri/src/services/acp/payloads.rs +++ b/ui/goose2/src-tauri/src/services/acp/payloads.rs @@ -3,7 +3,7 @@ use serde::Serialize; /// Payload for the `acp:message_created` event. #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] -pub(super) struct MessageCreatedPayload { +pub(crate) struct MessageCreatedPayload { pub session_id: String, pub message_id: String, pub persona_id: Option, @@ -13,7 +13,7 @@ pub(super) struct MessageCreatedPayload { /// Payload for the `acp:text` event. #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] -pub(super) struct TextPayload { +pub(crate) struct TextPayload { pub session_id: String, pub message_id: String, pub text: String, @@ -22,7 +22,7 @@ pub(super) struct TextPayload { /// Payload for the `acp:done` event. #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] -pub(super) struct DonePayload { +pub(crate) struct DonePayload { pub session_id: String, pub message_id: String, } @@ -30,7 +30,7 @@ pub(super) struct DonePayload { /// Payload for the `acp:tool_call` event. #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] -pub(super) struct ToolCallPayload { +pub(crate) struct ToolCallPayload { pub session_id: String, pub message_id: String, pub tool_call_id: String, @@ -40,7 +40,7 @@ pub(super) struct ToolCallPayload { /// Payload for the `acp:tool_title` event. #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] -pub(super) struct ToolTitlePayload { +pub(crate) struct ToolTitlePayload { pub session_id: String, pub message_id: String, pub tool_call_id: String, @@ -50,7 +50,7 @@ pub(super) struct ToolTitlePayload { /// Payload for the `acp:tool_result` event. #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] -pub(super) struct ToolResultPayload { +pub(crate) struct ToolResultPayload { pub session_id: String, pub message_id: String, pub content: String, @@ -58,14 +58,14 @@ pub(super) struct ToolResultPayload { #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] -pub(super) struct SessionInfoPayload { +pub(crate) struct SessionInfoPayload { pub session_id: String, pub title: Option, } #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] -pub(super) struct ModelStatePayload { +pub(crate) struct ModelStatePayload { pub session_id: String, pub current_model_id: String, pub current_model_name: Option, diff --git a/ui/goose2/src-tauri/src/services/acp/store.rs b/ui/goose2/src-tauri/src/services/acp/store.rs deleted file mode 100644 index 336da737e7..0000000000 --- a/ui/goose2/src-tauri/src/services/acp/store.rs +++ /dev/null @@ -1,190 +0,0 @@ -use std::path::PathBuf; -use std::sync::Arc; - -use acp_client::Store; - -use super::make_composite_key; -use crate::services::sessions::SessionStore; -use crate::types::messages::{Message, MessageContent, MessageRole}; - -/// A [`Store`] implementation that persists ACP session mappings to disk -/// under `~/.goose/acp_sessions/` and reads conversation history from the -/// [`SessionStore`]. -/// -/// When `persona_id` is set, the store uses a composite key -/// (`{sessionId}__{personaId}`) for both file-based session persistence and -/// message filtering, so each persona maintains an independent ACP agent -/// session and sees only its own conversation slice. -pub struct TauriStore { - sessions_dir: PathBuf, - session_store: Arc, - /// The raw Goose session ID (without persona suffix). - session_id: String, - /// Optional persona scoping. When `Some`, the effective key used for - /// on-disk persistence becomes `{session_id}__{persona_id}`. - persona_id: Option, -} - -impl TauriStore { - /// Create a new store, ensuring the backing directory exists. - /// - /// * `session_store` – shared session store for reading messages. - /// * `session_id` – the Goose chat session ID. - /// * `persona_id` – when `Some`, enables per-persona composite keying. - /// Pass `None` to preserve single-persona behaviour. - pub fn new( - session_store: Arc, - session_id: String, - persona_id: Option, - ) -> Self { - let sessions_dir = dirs::home_dir() - .expect("home dir") - .join(".goose") - .join("acp_sessions"); - let _ = std::fs::create_dir_all(&sessions_dir); - Self { - sessions_dir, - session_store, - session_id, - persona_id, - } - } - - // -- composite-key helpers ------------------------------------------------ - - /// Build the effective key used for on-disk session files. - /// Returns `"{session_id}__{persona_id}"` when a persona is set, - /// otherwise plain `"{session_id}"`. - fn effective_key(&self) -> String { - make_composite_key(&self.session_id, self.persona_id.as_deref()) - } - - /// Look up a previously stored agent session ID, or `None` for new sessions. - pub fn get_agent_session_id(&self) -> Option { - let key = self.effective_key(); - let path = self.sessions_dir.join(format!("{key}.json")); - let json = std::fs::read_to_string(&path).ok()?; - let mapping: serde_json::Value = serde_json::from_str(&json).ok()?; - mapping["agent_session_id"].as_str().map(String::from) - } - - /// Remove session files that are older than the given duration. - pub fn cleanup_stale_sessions(max_age: std::time::Duration) { - let sessions_dir = dirs::home_dir() - .expect("home dir") - .join(".goose") - .join("acp_sessions"); - - if let Ok(entries) = std::fs::read_dir(&sessions_dir) { - let cutoff = std::time::SystemTime::now() - max_age; - for entry in entries.flatten() { - if let Ok(metadata) = entry.metadata() { - if let Ok(modified) = metadata.modified() { - if modified < cutoff { - let _ = std::fs::remove_file(entry.path()); - } - } - } - } - } - } - - // -- persona-filtered message helpers ------------------------------------- - - /// Returns `true` if `msg` should be visible to the persona identified by - /// `persona_id`. - /// - /// Rules: - /// * **User messages** are included when the message's - /// `metadata.target_persona_id` equals `persona_id`, **or** when the - /// message's `metadata.persona_id` equals `persona_id`. - /// * **Assistant/System messages** are included when - /// `metadata.persona_id` equals `persona_id`. - /// * Messages with **no metadata** or no matching persona fields are - /// excluded. - fn message_matches_persona(msg: &Message, persona_id: &str) -> bool { - let meta = match &msg.metadata { - Some(m) => m, - None => return false, - }; - - match msg.role { - MessageRole::User => { - let target_match = meta.target_persona_id.as_deref() == Some(persona_id); - let persona_match = meta.persona_id.as_deref() == Some(persona_id); - target_match || persona_match - } - MessageRole::Assistant | MessageRole::System => { - meta.persona_id.as_deref() == Some(persona_id) - } - } - } - - /// Convert a [`Message`] into the `(role, text)` pair expected by the - /// ACP `Store` trait. - fn message_to_pair(msg: &Message) -> Option<(String, String)> { - let role = match msg.role { - MessageRole::User => "user", - MessageRole::Assistant => "assistant", - MessageRole::System => "system", - } - .to_string(); - - let text_parts: Vec = msg - .content - .iter() - .filter_map(|c| match c { - MessageContent::Text { text } => Some(text.clone()), - _ => None, - }) - .collect(); - - if text_parts.is_empty() { - None - } else { - Some((role, text_parts.join("\n"))) - } - } -} - -impl Store for TauriStore { - fn set_agent_session_id( - &self, - _session_id: &str, - agent_session_id: &str, - ) -> Result<(), String> { - let key = self.effective_key(); - let path = self.sessions_dir.join(format!("{key}.json")); - let payload = serde_json::json!({ - "session_id": self.session_id, - "persona_id": self.persona_id, - "agent_session_id": agent_session_id, - }); - let json = serde_json::to_string_pretty(&payload) - .map_err(|e| format!("Failed to serialize agent session mapping: {e}"))?; - std::fs::write(&path, json) - .map_err(|e| format!("Failed to write agent session file: {e}"))?; - Ok(()) - } - - fn get_session_messages(&self, _session_id: &str) -> Result, String> { - let messages = self.session_store.get_messages(&self.session_id); - - let pairs: Vec<(String, String)> = match &self.persona_id { - Some(pid) => { - // Persona mode: only return messages relevant to this persona. - messages - .iter() - .filter(|m| Self::message_matches_persona(m, pid)) - .filter_map(Self::message_to_pair) - .collect() - } - None => { - // Single-persona / backward-compat mode: return all messages. - messages.iter().filter_map(Self::message_to_pair).collect() - } - }; - - Ok(pairs) - } -} diff --git a/ui/goose2/src-tauri/src/services/acp/writer.rs b/ui/goose2/src-tauri/src/services/acp/writer.rs index 3dbe241dda..a4ff28261c 100644 --- a/ui/goose2/src-tauri/src/services/acp/writer.rs +++ b/ui/goose2/src-tauri/src/services/acp/writer.rs @@ -1,35 +1,20 @@ -use std::sync::Arc; - use async_trait::async_trait; use tauri::Emitter; use acp_client::{MessageWriter, SessionInfoUpdate, SessionModelState}; -use crate::services::sessions::SessionStore; -use crate::types::messages::{ - Message, MessageCompletionStatus, MessageContent, MessageMetadata, MessageRole, ToolCallStatus, -}; - use super::payloads::{ DonePayload, MessageCreatedPayload, ModelStatePayload, SessionInfoPayload, TextPayload, ToolCallPayload, ToolResultPayload, ToolTitlePayload, }; /// A [`MessageWriter`] implementation that streams ACP output to the frontend -/// via Tauri events, and saves the final assistant message to the -/// [`SessionStore`] on finalization. -struct PersistedMessageState { - content: Vec, - has_unflushed_changes: bool, -} - +/// via Tauri events. No local persistence — the goose binary is the sole +/// source of truth for messages. pub struct TauriMessageWriter { app_handle: tauri::AppHandle, session_id: String, - session_store: Arc, assistant_message_id: String, - /// Accumulated structured content (text + tool calls/results) for persistence. - state: std::sync::Mutex, } impl TauriMessageWriter { @@ -37,32 +22,10 @@ impl TauriMessageWriter { pub fn new( app_handle: tauri::AppHandle, session_id: String, - session_store: Arc, persona_id: Option, persona_name: Option, ) -> Self { let assistant_message_id = uuid::Uuid::new_v4().to_string(); - let message = Message { - id: assistant_message_id.clone(), - role: MessageRole::Assistant, - created: chrono::Utc::now().timestamp_millis(), - content: Vec::new(), - metadata: Some(MessageMetadata { - user_visible: Some(true), - agent_visible: Some(true), - persona_id: persona_id.clone(), - persona_name: persona_name.clone(), - completion_status: Some(MessageCompletionStatus::InProgress), - ..Default::default() - }), - }; - - if let Err(error) = session_store.add_message(&session_id, message) { - eprintln!( - "Failed to create streaming assistant message for session {}: {}", - session_id, error - ); - } let _ = app_handle.emit( "acp:message_created", @@ -77,95 +40,13 @@ impl TauriMessageWriter { Self { app_handle, session_id, - session_store, assistant_message_id, - state: std::sync::Mutex::new(PersistedMessageState { - content: Vec::new(), - has_unflushed_changes: false, - }), } } pub fn assistant_message_id(&self) -> &str { &self.assistant_message_id } - - fn persist_snapshot(&self, completion_status: Option) { - let (should_persist_content, content) = { - let mut state = self.state.lock().expect("state lock"); - let should_persist = state.has_unflushed_changes || completion_status.is_some(); - if should_persist { - state.has_unflushed_changes = false; - } - (should_persist, state.content.clone()) - }; - - if !should_persist_content { - return; - } - - if let Err(error) = self.session_store.update_message( - &self.session_id, - &self.assistant_message_id, - |message| { - message.content = content.clone(); - if let Some(status) = completion_status.clone() { - let metadata = message - .metadata - .get_or_insert_with(MessageMetadata::default); - let current = metadata.completion_status.clone(); - let should_replace = !matches!( - current, - Some(MessageCompletionStatus::Stopped) - | Some(MessageCompletionStatus::Error) - ) || matches!( - status, - MessageCompletionStatus::Stopped | MessageCompletionStatus::Error - ); - if should_replace { - metadata.completion_status = Some(status); - } - } - }, - ) { - eprintln!( - "Failed to update assistant message {} for session {}: {}", - self.assistant_message_id, self.session_id, error - ); - } - } -} - -fn append_text_block(content: &mut Vec, text: &str) { - if text.is_empty() { - return; - } - - match content.last_mut() { - Some(MessageContent::Text { text: existing }) => existing.push_str(text), - _ => content.push(MessageContent::Text { - text: text.to_string(), - }), - } -} - -fn find_latest_unpaired_tool_request( - content: &[MessageContent], -) -> Option<(usize, String, String)> { - for index in (0..content.len()).rev() { - let MessageContent::ToolRequest { id, name, .. } = &content[index] else { - continue; - }; - - let already_has_response = content.iter().any( - |candidate| matches!(candidate, MessageContent::ToolResponse { id: response_id, .. } if response_id == id), - ); - if !already_has_response { - return Some((index, id.clone(), name.clone())); - } - } - - None } #[async_trait] @@ -175,19 +56,6 @@ impl MessageWriter for TauriMessageWriter { return; } - { - let mut state = self.state.lock().expect("state lock"); - let starts_new_text_block = - !matches!(state.content.last(), Some(MessageContent::Text { .. })); - if starts_new_text_block { - drop(state); - self.persist_snapshot(None); - state = self.state.lock().expect("state lock"); - } - append_text_block(&mut state.content, text); - state.has_unflushed_changes = true; - } - let _ = self.app_handle.emit( "acp:text", TextPayload { @@ -199,8 +67,6 @@ impl MessageWriter for TauriMessageWriter { } async fn finalize(&self) { - self.persist_snapshot(Some(MessageCompletionStatus::Completed)); - let _ = self.app_handle.emit( "acp:done", DonePayload { @@ -211,19 +77,6 @@ impl MessageWriter for TauriMessageWriter { } async fn record_tool_call(&self, tool_call_id: &str, title: &str) { - self.persist_snapshot(None); - - { - let mut state = self.state.lock().expect("state lock"); - state.content.push(MessageContent::ToolRequest { - id: tool_call_id.to_string(), - name: title.to_string(), - arguments: serde_json::json!({}), - status: ToolCallStatus::Executing, - }); - state.has_unflushed_changes = true; - } - let _ = self.app_handle.emit( "acp:tool_call", ToolCallPayload { @@ -236,20 +89,6 @@ impl MessageWriter for TauriMessageWriter { } async fn update_tool_call_title(&self, tool_call_id: &str, title: &str) { - { - let mut state = self.state.lock().expect("state lock"); - for block in state.content.iter_mut().rev() { - if let MessageContent::ToolRequest { id, name, .. } = block { - if id == tool_call_id { - *name = title.to_string(); - break; - } - } - } - state.has_unflushed_changes = true; - } - self.persist_snapshot(None); - let _ = self.app_handle.emit( "acp:tool_title", ToolTitlePayload { @@ -262,37 +101,6 @@ impl MessageWriter for TauriMessageWriter { } async fn record_tool_result(&self, content: &str) { - self.persist_snapshot(None); - - { - let mut state = self.state.lock().expect("state lock"); - - let paired_request = find_latest_unpaired_tool_request(&state.content); - - if let Some((request_index, tool_call_id, tool_name)) = paired_request { - if let MessageContent::ToolRequest { status, .. } = - &mut state.content[request_index] - { - *status = ToolCallStatus::Completed; - } - - state.content.push(MessageContent::ToolResponse { - id: tool_call_id, - name: tool_name, - result: content.to_string(), - is_error: false, - }); - } else { - state.content.push(MessageContent::ToolResponse { - id: uuid::Uuid::new_v4().to_string(), - name: String::new(), - result: content.to_string(), - is_error: false, - }); - } - state.has_unflushed_changes = true; - } - let _ = self.app_handle.emit( "acp:tool_result", ToolResultPayload { diff --git a/ui/goose2/src-tauri/src/services/mod.rs b/ui/goose2/src-tauri/src/services/mod.rs index 316a531afc..c0b447c6b4 100644 --- a/ui/goose2/src-tauri/src/services/mod.rs +++ b/ui/goose2/src-tauri/src/services/mod.rs @@ -1,3 +1,2 @@ pub mod acp; pub mod personas; -pub mod sessions; diff --git a/ui/goose2/src-tauri/src/services/sessions.rs b/ui/goose2/src-tauri/src/services/sessions.rs deleted file mode 100644 index c370c20dad..0000000000 --- a/ui/goose2/src-tauri/src/services/sessions.rs +++ /dev/null @@ -1,317 +0,0 @@ -use crate::types::agents::{Session, SessionUpdate}; -use crate::types::messages::Message; -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::Mutex; - -pub struct SessionStore { - /// Session metadata indexed by session ID. Messages are loaded lazily from disk. - sessions: Mutex>, - sessions_dir: PathBuf, -} - -impl SessionStore { - fn message_preview(message: &Message) -> Option { - for content in &message.content { - if let crate::types::messages::MessageContent::Text { text } = content { - let cutoff = text.char_indices().nth(100).map(|(index, _)| index); - return Some(match cutoff { - Some(index) => format!("{}...", &text[..index]), - None => text.clone(), - }); - } - } - - None - } - - fn refresh_session_message_metadata(session: &mut Session, messages: &[Message]) { - session.message_count = messages.len() as u32; - session.last_message_preview = messages.iter().rev().find_map(Self::message_preview); - session.updated_at = chrono::Utc::now().to_rfc3339(); - } - - pub fn new() -> Self { - let sessions_dir = dirs::home_dir() - .expect("home dir") - .join(".goose") - .join("sessions"); - let _ = std::fs::create_dir_all(&sessions_dir); - - let metadata_file = sessions_dir.join("metadata.json"); - let sessions: HashMap = std::fs::read_to_string(&metadata_file) - .ok() - .and_then(|json| serde_json::from_str(&json).ok()) - .unwrap_or_default(); - - Self { - sessions: Mutex::new(sessions), - sessions_dir, - } - } - - /// Persist the full metadata map to a single file. - fn save_metadata(&self, sessions: &HashMap) { - let metadata_file = self.sessions_dir.join("metadata.json"); - match serde_json::to_string_pretty(sessions) { - Ok(json) => { - if let Err(e) = std::fs::write(&metadata_file, json) { - eprintln!( - "Failed to write session metadata to {}: {}", - metadata_file.display(), - e - ); - } - } - Err(e) => { - eprintln!("Failed to serialize session metadata: {}", e); - } - } - } - - /// Persist messages for a single session to its directory. - fn save_messages(&self, session_id: &str, messages: &[Message]) { - let dir = self.sessions_dir.join(session_id); - if let Err(e) = std::fs::create_dir_all(&dir) { - eprintln!( - "Failed to create session directory {}: {}", - dir.display(), - e - ); - return; - } - match serde_json::to_string_pretty(messages) { - Ok(json) => { - let path = dir.join("messages.json"); - if let Err(e) = std::fs::write(&path, &json) { - eprintln!("Failed to write messages for session {}: {}", session_id, e); - } - } - Err(e) => { - eprintln!( - "Failed to serialize messages for session {}: {}", - session_id, e - ); - } - } - } - - /// Load messages for a session from disk. - fn load_messages(&self, session_id: &str) -> Vec { - let path = self.sessions_dir.join(session_id).join("messages.json"); - std::fs::read_to_string(&path) - .ok() - .and_then(|json| serde_json::from_str(&json).ok()) - .unwrap_or_default() - } - - pub fn create_session(&self, agent_id: Option, project_id: Option) -> Session { - let now = chrono::Utc::now().to_rfc3339(); - let id = uuid::Uuid::new_v4().to_string(); - let session = Session { - id: id.clone(), - title: "New Chat".to_string(), - agent_id, - project_id, - provider_id: None, - persona_id: None, - model_name: None, - created_at: now.clone(), - updated_at: now, - message_count: 0, - last_message_preview: None, - archived_at: None, - }; - - // Write empty messages file - self.save_messages(&id, &[]); - - let mut sessions = self.sessions.lock().unwrap(); - sessions.insert(id, session.clone()); - self.save_metadata(&sessions); - session - } - - #[allow(dead_code)] - pub fn get_session(&self, id: &str) -> Option { - let sessions = self.sessions.lock().unwrap(); - sessions.get(id).cloned() - } - - /// Ensure a session with the given ID exists, creating it if necessary. - /// Returns the session (existing or newly created). - pub fn ensure_session(&self, id: &str, agent_id: Option) -> Session { - use std::collections::hash_map::Entry; - - let mut sessions = self.sessions.lock().unwrap(); - match sessions.entry(id.to_string()) { - Entry::Occupied(entry) => entry.get().clone(), - Entry::Vacant(entry) => { - let now = chrono::Utc::now().to_rfc3339(); - let session = Session { - id: id.to_string(), - title: "New Chat".to_string(), - agent_id, - project_id: None, - provider_id: None, - persona_id: None, - model_name: None, - created_at: now.clone(), - updated_at: now, - message_count: 0, - last_message_preview: None, - archived_at: None, - }; - - self.save_messages(id, &[]); - entry.insert(session.clone()); - self.save_metadata(&sessions); - session - } - } - } - - pub fn list_sessions(&self) -> Vec { - let sessions = self.sessions.lock().unwrap(); - let mut list: Vec = sessions - .values() - .filter(|s| s.archived_at.is_none()) - .cloned() - .collect(); - // Sort by updated_at descending (most recent first) - list.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); - list - } - - pub fn list_archived_sessions(&self) -> Vec { - let sessions = self.sessions.lock().unwrap(); - let mut list: Vec = sessions - .values() - .filter(|s| s.archived_at.is_some()) - .cloned() - .collect(); - // Sort by archived_at descending (most recently archived first) - list.sort_by(|a, b| b.archived_at.cmp(&a.archived_at)); - list - } - - pub fn archive_session(&self, id: &str) -> Result<(), String> { - let mut sessions = self.sessions.lock().unwrap(); - let session = sessions - .get_mut(id) - .ok_or_else(|| format!("Session '{}' not found", id))?; - - session.archived_at = Some(chrono::Utc::now().to_rfc3339()); - self.save_metadata(&sessions); - Ok(()) - } - - pub fn unarchive_session(&self, id: &str) -> Result<(), String> { - let mut sessions = self.sessions.lock().unwrap(); - let session = sessions - .get_mut(id) - .ok_or_else(|| format!("Session '{}' not found", id))?; - - session.archived_at = None; - self.save_metadata(&sessions); - Ok(()) - } - - pub fn add_message(&self, session_id: &str, message: Message) -> Result<(), String> { - let mut sessions = self.sessions.lock().unwrap(); - let session = sessions - .get_mut(session_id) - .ok_or_else(|| format!("Session '{}' not found", session_id))?; - - let mut messages = self.load_messages(session_id); - messages.push(message); - Self::refresh_session_message_metadata(session, &messages); - self.save_metadata(&sessions); - self.save_messages(session_id, &messages); - - Ok(()) - } - - pub fn update_message( - &self, - session_id: &str, - message_id: &str, - updater: F, - ) -> Result<(), String> - where - F: FnOnce(&mut Message), - { - let mut sessions = self.sessions.lock().unwrap(); - let session = sessions - .get_mut(session_id) - .ok_or_else(|| format!("Session '{}' not found", session_id))?; - - let mut messages = self.load_messages(session_id); - let message = messages - .iter_mut() - .find(|message| message.id == message_id) - .ok_or_else(|| { - format!( - "Message '{}' not found in session '{}'", - message_id, session_id - ) - })?; - updater(message); - - Self::refresh_session_message_metadata(session, &messages); - self.save_metadata(&sessions); - self.save_messages(session_id, &messages); - - Ok(()) - } - - pub fn get_messages(&self, session_id: &str) -> Vec { - // Acquire the mutex to avoid reading a partially-written messages file - let _guard = self.sessions.lock().unwrap(); - self.load_messages(session_id) - } - - pub fn update_session(&self, id: &str, update: SessionUpdate) -> Result<(), String> { - let mut sessions = self.sessions.lock().unwrap(); - let session = sessions - .get_mut(id) - .ok_or_else(|| format!("Session '{}' not found", id))?; - - let mut should_bump_timestamp = false; - if let Some(title) = update.title { - session.title = title; - should_bump_timestamp = true; - } - if let Some(provider_id) = update.provider_id { - session.provider_id = Some(provider_id); - } - if let Some(persona_id) = update.persona_id { - session.persona_id = Some(persona_id); - } - if let Some(model_name) = update.model_name { - session.model_name = Some(model_name); - } - if let Some(project_id) = update.project_id { - session.project_id = project_id; - } - if should_bump_timestamp { - session.updated_at = chrono::Utc::now().to_rfc3339(); - } - self.save_metadata(&sessions); - Ok(()) - } - - pub fn delete_session(&self, id: &str) -> Result<(), String> { - let mut sessions = self.sessions.lock().unwrap(); - sessions - .remove(id) - .ok_or_else(|| format!("Session '{}' not found", id))?; - - self.save_metadata(&sessions); - - // Remove messages directory from disk - let dir = self.sessions_dir.join(id); - let _ = std::fs::remove_dir_all(dir); - Ok(()) - } -} diff --git a/ui/goose2/src-tauri/src/types/messages.rs b/ui/goose2/src-tauri/src/types/messages.rs index 2ce0f2dae3..bd40c33035 100644 --- a/ui/goose2/src-tauri/src/types/messages.rs +++ b/ui/goose2/src-tauri/src/types/messages.rs @@ -121,7 +121,6 @@ pub struct MessageAttachment { pub url: Option, } -#[allow(dead_code)] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TokenState { diff --git a/ui/goose2/src-tauri/src/types/mod.rs b/ui/goose2/src-tauri/src/types/mod.rs index 226d97a01b..a49df73eee 100644 --- a/ui/goose2/src-tauri/src/types/mod.rs +++ b/ui/goose2/src-tauri/src/types/mod.rs @@ -1,3 +1,5 @@ +#[allow(dead_code)] pub mod agents; pub mod builtin_personas; +#[allow(dead_code)] pub mod messages; diff --git a/ui/goose2/src/app/AppShell.tsx b/ui/goose2/src/app/AppShell.tsx index 9ac8cb6275..9ee7ac6602 100644 --- a/ui/goose2/src/app/AppShell.tsx +++ b/ui/goose2/src/app/AppShell.tsx @@ -19,7 +19,6 @@ import { useAcpStream } from "@/features/chat/hooks/useAcpStream"; import { useAgentStore } from "@/features/agents/stores/agentStore"; import { useProjectStore } from "@/features/projects/stores/projectStore"; import { findExistingDraft } from "@/features/chat/lib/newChat"; -import { getSessionMessages } from "@/shared/api/chat"; import { useAppStartup } from "./hooks/useAppStartup"; export type AppView = @@ -59,35 +58,45 @@ export function AppShell({ children }: { children?: React.ReactNode }) { useAcpStream(true); - const loadingSessionsRef = useRef>(new Set()); const pendingProjectCreatedRef = useRef<((projectId: string) => void) | null>( null, ); + // TODO: wire to ACP load_session replay instead of fetching from SessionStore const loadSessionMessages = useCallback(async (sessionId: string) => { - const session = useChatSessionStore - .getState() - .sessions.find((s) => s.id === sessionId); - if (session?.draft) return; - const { messagesBySession, setMessages } = useChatStore.getState(); - const existing = messagesBySession[sessionId]; - if ( - (existing && existing.length > 0) || - loadingSessionsRef.current.has(sessionId) - ) { + // Skip if we already have messages for this session (e.g. it was just created) + const existing = useChatStore.getState().messagesBySession[sessionId]; + if (existing && existing.length > 0) { + console.log( + `[perf:load] ${sessionId.slice(0, 8)} skip — already has messages`, + ); return; } - loadingSessionsRef.current.add(sessionId); + + const t0 = performance.now(); + console.log(`[perf:load] ${sessionId.slice(0, 8)} start`); + const store = useChatStore.getState(); + store.setSessionLoading(sessionId, true); try { - const messages = await getSessionMessages(sessionId); - const current = useChatStore.getState().messagesBySession[sessionId]; - if (!current || current.length === 0) { - setMessages(sessionId, messages); - } + const t1 = performance.now(); + const { acpLoadSession } = await import("@/shared/api/acp"); + const t2 = performance.now(); + console.log( + `[perf:load] ${sessionId.slice(0, 8)} import took ${(t2 - t1).toFixed(1)}ms`, + ); + // For sessions loaded from ACP, the session ID is the goose session ID + await acpLoadSession(sessionId, sessionId); + const t3 = performance.now(); + console.log( + `[perf:load] ${sessionId.slice(0, 8)} acpLoadSession resolved in ${(t3 - t2).toFixed(1)}ms (total ${(t3 - t0).toFixed(1)}ms)`, + ); } catch (err) { - console.error(`Failed to load messages for session ${sessionId}:`, err); + console.error("Failed to load session messages:", err); } finally { - loadingSessionsRef.current.delete(sessionId); + useChatStore.getState().setSessionLoading(sessionId, false); + console.log( + `[perf:load] ${sessionId.slice(0, 8)} done, loading=false at +${(performance.now() - t0).toFixed(1)}ms`, + ); } }, []); diff --git a/ui/goose2/src/app/hooks/useAppStartup.ts b/ui/goose2/src/app/hooks/useAppStartup.ts index c992d33d25..6ba232263f 100644 --- a/ui/goose2/src/app/hooks/useAppStartup.ts +++ b/ui/goose2/src/app/hooks/useAppStartup.ts @@ -33,9 +33,14 @@ export function useAppStartup() { }; const loadSessionState = async () => { + const t0 = performance.now(); + console.log("[perf:startup] loadSessionState start"); const { loadSessions, setActiveSession } = useChatSessionStore.getState(); await loadSessions(); + console.log( + `[perf:startup] loadSessions done in ${(performance.now() - t0).toFixed(1)}ms`, + ); setActiveSession(null); }; diff --git a/ui/goose2/src/features/chat/hooks/useAcpStream.ts b/ui/goose2/src/features/chat/hooks/useAcpStream.ts index c91ecbf9db..7aceb1e452 100644 --- a/ui/goose2/src/features/chat/hooks/useAcpStream.ts +++ b/ui/goose2/src/features/chat/hooks/useAcpStream.ts @@ -155,15 +155,23 @@ export function useAcpStream(enabled: boolean): void { unlisteners.push( listen("acp:message_created", (event) => { if (!active) return; + console.log( + `[perf:stream] ${event.payload.sessionId.slice(0, 8)} message_created mid=${event.payload.messageId.slice(0, 8)} at ${performance.now().toFixed(1)}ms`, + ); const store = useChatStore.getState(); - if ( - !shouldTrackStreamingEvent( - store, - event.payload.sessionId, - event.payload.messageId, - ) - ) { - return; + + // Accept if: session is loading (replay), or we're actively streaming/thinking + const isLoading = store.loadingSessionIds.has(event.payload.sessionId); + if (!isLoading) { + if ( + !shouldTrackStreamingEvent( + store, + event.payload.sessionId, + event.payload.messageId, + ) + ) { + return; + } } const existing = store.messagesBySession[event.payload.sessionId]?.find( @@ -197,6 +205,9 @@ export function useAcpStream(enabled: boolean): void { unlisteners.push( listen("acp:text", (event) => { if (!active) return; + console.log( + `[perf:stream] ${event.payload.sessionId.slice(0, 8)} text mid=${event.payload.messageId.slice(0, 8)} len=${event.payload.text.length} at ${performance.now().toFixed(1)}ms`, + ); const store = useChatStore.getState(); if ( !shouldTrackStreamingEvent( @@ -219,13 +230,33 @@ export function useAcpStream(enabled: boolean): void { unlisteners.push( listen("acp:done", (event) => { if (!active) return; + console.log( + `[perf:stream] ${event.payload.sessionId.slice(0, 8)} done mid=${event.payload.messageId.slice(0, 8)} at ${performance.now().toFixed(1)}ms`, + ); const store = useChatStore.getState(); + const isLoading = store.loadingSessionIds.has(event.payload.sessionId); + store.updateMessage( event.payload.sessionId, event.payload.messageId, - (message) => updateCompletionStatus(message, "completed"), + (message) => { + // Mark any tool requests still in "executing" as completed. + // During replay, tool_result events may not fire for every + // tool call (e.g. when the result content type has no preview), + // so we finalize them here to stop running timers. + const content = message.content.map((block) => + block.type === "toolRequest" && block.status === "executing" + ? { ...block, status: "completed" as const } + : block, + ); + return updateCompletionStatus({ ...message, content }, "completed"); + }, ); store.setStreamingMessageId(event.payload.sessionId, null); + + // During replay, don't reset chat state or trigger side effects + if (isLoading) return; + store.setChatState(event.payload.sessionId, "idle"); if ( useChatSessionStore.getState().activeSessionId !== @@ -357,6 +388,30 @@ export function useAcpStream(enabled: boolean): void { ); // acp:session_info — update session title from ACP provider + // Replay: user messages from load_session history + unlisteners.push( + listen<{ sessionId: string; messageId: string; text: string }>( + "acp:replay_user_message", + (event) => { + if (!active) return; + console.log( + `[perf:stream] ${event.payload.sessionId.slice(0, 8)} replay_user_message at ${performance.now().toFixed(1)}ms`, + ); + const store = useChatStore.getState(); + store.addMessage(event.payload.sessionId, { + id: event.payload.messageId, + role: "user", + created: Date.now(), + content: [{ type: "text", text: event.payload.text }], + metadata: { + userVisible: true, + agentVisible: true, + }, + }); + }, + ), + ); + unlisteners.push( listen("acp:session_info", (event) => { if (!active) return; diff --git a/ui/goose2/src/features/chat/stores/__tests__/chatSessionStore.test.ts b/ui/goose2/src/features/chat/stores/__tests__/chatSessionStore.test.ts new file mode 100644 index 0000000000..662571e8af --- /dev/null +++ b/ui/goose2/src/features/chat/stores/__tests__/chatSessionStore.test.ts @@ -0,0 +1,266 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AcpSessionInfo } from "@/shared/api/acp"; +import { useChatSessionStore } from "../chatSessionStore"; + +vi.mock("@/shared/api/acp", () => ({ + acpListSessions: vi.fn(), +})); + +import { acpListSessions } from "@/shared/api/acp"; + +const mockedAcpListSessions = vi.mocked(acpListSessions); + +const SESSION_CACHE_KEY = "goose:chat-sessions"; + +function resetStore() { + useChatSessionStore.setState({ + sessions: [], + activeSessionId: null, + isLoading: false, + contextPanelOpenBySession: {}, + }); +} + +describe("chatSessionStore", () => { + beforeEach(() => { + resetStore(); + window.localStorage.removeItem(SESSION_CACHE_KEY); + vi.clearAllMocks(); + }); + + afterEach(() => { + window.localStorage.removeItem(SESSION_CACHE_KEY); + }); + + describe("createDraftSession", () => { + it("creates a draft session with default title", () => { + const session = useChatSessionStore.getState().createDraftSession(); + + expect(session.title).toBe("New Chat"); + expect(session.draft).toBe(true); + expect(session.messageCount).toBe(0); + expect(useChatSessionStore.getState().sessions).toContainEqual(session); + }); + + it("creates a draft session with custom options", () => { + const session = useChatSessionStore.getState().createDraftSession({ + title: "My Custom Chat", + projectId: "proj-1", + providerId: "openai", + personaId: "persona-1", + }); + + expect(session.title).toBe("My Custom Chat"); + expect(session.projectId).toBe("proj-1"); + expect(session.providerId).toBe("openai"); + expect(session.personaId).toBe("persona-1"); + expect(session.draft).toBe(true); + }); + }); + + describe("promoteDraft", () => { + it("removes draft flag from a draft session", () => { + const session = useChatSessionStore.getState().createDraftSession(); + expect(session.draft).toBe(true); + + useChatSessionStore.getState().promoteDraft(session.id); + + const updated = useChatSessionStore + .getState() + .sessions.find((s) => s.id === session.id); + expect(updated?.draft).toBeUndefined(); + }); + + it("does nothing for non-draft sessions", () => { + useChatSessionStore.setState({ + sessions: [ + { + id: "non-draft", + title: "Regular Session", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + messageCount: 5, + }, + ], + }); + + useChatSessionStore.getState().promoteDraft("non-draft"); + + const session = useChatSessionStore + .getState() + .sessions.find((s) => s.id === "non-draft"); + expect(session?.draft).toBeUndefined(); + }); + }); + + describe("removeDraft", () => { + it("removes a draft session", () => { + const session = useChatSessionStore.getState().createDraftSession(); + expect(useChatSessionStore.getState().sessions).toHaveLength(1); + + useChatSessionStore.getState().removeDraft(session.id); + + expect(useChatSessionStore.getState().sessions).toHaveLength(0); + }); + + it("clears activeSessionId if removing the active draft", () => { + const session = useChatSessionStore.getState().createDraftSession(); + useChatSessionStore.getState().setActiveSession(session.id); + + useChatSessionStore.getState().removeDraft(session.id); + + expect(useChatSessionStore.getState().activeSessionId).toBeNull(); + }); + + it("does not remove non-draft sessions", () => { + useChatSessionStore.setState({ + sessions: [ + { + id: "non-draft", + title: "Regular Session", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + messageCount: 5, + }, + ], + }); + + useChatSessionStore.getState().removeDraft("non-draft"); + + expect(useChatSessionStore.getState().sessions).toHaveLength(1); + }); + }); + + describe("loadSessions", () => { + it("loads sessions from ACP and maps them correctly", async () => { + mockedAcpListSessions.mockResolvedValue([ + { sessionId: "acp-1", title: "ACP Session 1", updatedAt: "2026-04-01" }, + { sessionId: "acp-2", title: null, updatedAt: "2026-04-02" }, + ]); + + await useChatSessionStore.getState().loadSessions(); + + const sessions = useChatSessionStore.getState().sessions; + expect(sessions).toHaveLength(2); + expect(sessions[0].id).toBe("acp-2"); // Most recent first + expect(sessions[0].title).toBe("Untitled"); // null title becomes "Untitled" + expect(sessions[1].id).toBe("acp-1"); + expect(sessions[1].title).toBe("ACP Session 1"); + }); + + it("preserves local drafts alongside ACP sessions", async () => { + const draft = useChatSessionStore.getState().createDraftSession({ + title: "My Draft", + }); + + mockedAcpListSessions.mockResolvedValue([ + { sessionId: "acp-1", title: "ACP Session", updatedAt: "2026-04-01" }, + ]); + + await useChatSessionStore.getState().loadSessions(); + + const sessions = useChatSessionStore.getState().sessions; + expect(sessions).toHaveLength(2); + expect(sessions.find((s) => s.id === "acp-1")).toBeDefined(); + expect(sessions.find((s) => s.id === draft.id)).toBeDefined(); + }); + + it("sets isLoading during fetch", async () => { + let resolvePromise: (value: AcpSessionInfo[]) => void = () => {}; + mockedAcpListSessions.mockReturnValue( + new Promise((resolve) => { + resolvePromise = resolve; + }), + ); + + const loadPromise = useChatSessionStore.getState().loadSessions(); + expect(useChatSessionStore.getState().isLoading).toBe(true); + + resolvePromise([]); + await loadPromise; + + expect(useChatSessionStore.getState().isLoading).toBe(false); + }); + + it("falls back to cached drafts on error", async () => { + window.localStorage.setItem( + SESSION_CACHE_KEY, + JSON.stringify([ + { + id: "cached-draft", + title: "Cached Draft", + draft: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + messageCount: 0, + }, + ]), + ); + + mockedAcpListSessions.mockRejectedValue(new Error("Network error")); + + await useChatSessionStore.getState().loadSessions(); + + const sessions = useChatSessionStore.getState().sessions; + expect(sessions).toHaveLength(1); + expect(sessions[0].id).toBe("cached-draft"); + expect(sessions[0].draft).toBe(true); + }); + }); + + describe("updateSession", () => { + it("updates session properties", () => { + const session = useChatSessionStore.getState().createDraftSession(); + + useChatSessionStore.getState().updateSession(session.id, { + title: "Updated Title", + projectId: "new-project", + }); + + const updated = useChatSessionStore + .getState() + .sessions.find((s) => s.id === session.id); + expect(updated?.title).toBe("Updated Title"); + expect(updated?.projectId).toBe("new-project"); + }); + + it("updates the updatedAt timestamp", () => { + const session = useChatSessionStore.getState().createDraftSession(); + const originalUpdatedAt = session.updatedAt; + + // Small delay to ensure timestamp changes + vi.useFakeTimers(); + vi.advanceTimersByTime(1000); + + useChatSessionStore.getState().updateSession(session.id, { + title: "New Title", + }); + + vi.useRealTimers(); + + const updated = useChatSessionStore + .getState() + .sessions.find((s) => s.id === session.id); + expect(updated?.updatedAt).not.toBe(originalUpdatedAt); + }); + }); + + describe("archiveSession", () => { + it("removes the session from the list", async () => { + const session = useChatSessionStore.getState().createDraftSession(); + + await useChatSessionStore.getState().archiveSession(session.id); + + expect(useChatSessionStore.getState().sessions).toHaveLength(0); + }); + + it("clears activeSessionId if archiving the active session", async () => { + const session = useChatSessionStore.getState().createDraftSession(); + useChatSessionStore.getState().setActiveSession(session.id); + + await useChatSessionStore.getState().archiveSession(session.id); + + expect(useChatSessionStore.getState().activeSessionId).toBeNull(); + }); + }); +}); diff --git a/ui/goose2/src/features/chat/stores/__tests__/chatStore.test.ts b/ui/goose2/src/features/chat/stores/__tests__/chatStore.test.ts index 31d9982cd2..cf03fb637e 100644 --- a/ui/goose2/src/features/chat/stores/__tests__/chatStore.test.ts +++ b/ui/goose2/src/features/chat/stores/__tests__/chatStore.test.ts @@ -220,3 +220,60 @@ describe("chatStore draft localStorage persistence", () => { expect(stored).toEqual({ s2: "world" }); }); }); + +describe("chatStore session loading state", () => { + beforeEach(() => { + useChatStore.setState({ + messagesBySession: {}, + sessionStateById: {}, + queuedMessageBySession: {}, + draftsBySession: {}, + activeSessionId: null, + isConnected: false, + loadingSessionIds: new Set(), + }); + }); + + it("starts with empty loadingSessionIds", () => { + expect(useChatStore.getState().loadingSessionIds.size).toBe(0); + }); + + it("adds session to loadingSessionIds when setSessionLoading(true)", () => { + useChatStore.getState().setSessionLoading("s1", true); + + expect(useChatStore.getState().loadingSessionIds.has("s1")).toBe(true); + }); + + it("removes session from loadingSessionIds when setSessionLoading(false)", () => { + useChatStore.getState().setSessionLoading("s1", true); + useChatStore.getState().setSessionLoading("s1", false); + + expect(useChatStore.getState().loadingSessionIds.has("s1")).toBe(false); + }); + + it("tracks multiple sessions independently", () => { + useChatStore.getState().setSessionLoading("s1", true); + useChatStore.getState().setSessionLoading("s2", true); + + expect(useChatStore.getState().loadingSessionIds.has("s1")).toBe(true); + expect(useChatStore.getState().loadingSessionIds.has("s2")).toBe(true); + + useChatStore.getState().setSessionLoading("s1", false); + + expect(useChatStore.getState().loadingSessionIds.has("s1")).toBe(false); + expect(useChatStore.getState().loadingSessionIds.has("s2")).toBe(true); + }); + + it("is idempotent for adding the same session", () => { + useChatStore.getState().setSessionLoading("s1", true); + useChatStore.getState().setSessionLoading("s1", true); + + expect(useChatStore.getState().loadingSessionIds.size).toBe(1); + }); + + it("is idempotent for removing a non-existent session", () => { + useChatStore.getState().setSessionLoading("s1", false); + + expect(useChatStore.getState().loadingSessionIds.size).toBe(0); + }); +}); diff --git a/ui/goose2/src/features/chat/stores/chatSessionStore.ts b/ui/goose2/src/features/chat/stores/chatSessionStore.ts index d1be649a6c..6fe129a547 100644 --- a/ui/goose2/src/features/chat/stores/chatSessionStore.ts +++ b/ui/goose2/src/features/chat/stores/chatSessionStore.ts @@ -1,14 +1,6 @@ import { create } from "zustand"; -import { - createSession as apiCreateSession, - listSessions as apiListSessions, - archiveSession as apiArchiveSession, - unarchiveSession as apiUnarchiveSession, - updateSession as apiUpdateSession, -} from "@/shared/api/chat"; import type { Session } from "@/shared/types/chat"; - -const SESSION_CACHE_STORAGE_KEY = "goose:chat-sessions"; +import { acpListSessions } from "@/shared/api/acp"; // Extended session metadata used by the frontend session list export interface ChatSession { @@ -68,6 +60,8 @@ interface ChatSessionStoreActions { export type ChatSessionStore = ChatSessionStoreState & ChatSessionStoreActions; +const SESSION_CACHE_STORAGE_KEY = "goose:chat-sessions"; + function loadCachedSessions(): ChatSession[] { if (typeof window === "undefined") return []; try { @@ -110,7 +104,8 @@ function persistSessions(sessions: ChatSession[]): void { } } -function sessionToChatSession(session: Session): ChatSession { +/** Map a backend Session to the frontend ChatSession shape. */ +export function sessionToChatSession(session: Session): ChatSession { return { id: session.id, title: session.title, @@ -129,43 +124,19 @@ function sessionToChatSession(session: Session): ChatSession { export const useChatSessionStore = create((set, get) => ({ // State - sessions: loadCachedSessions(), + sessions: [], activeSessionId: null, isLoading: false, contextPanelOpenBySession: {}, - // Session lifecycle - createSession: async (opts) => { - const backendSession = await apiCreateSession( - opts?.agentId, - opts?.projectId, + // Session lifecycle — local-only for now. + // The goose binary creates the real ACP session on first prompt + // (via prepare_session / send_prompt). These just manage the + // frontend's in-memory session list. + createSession: async (_opts) => { + throw new Error( + "createSession not yet wired to ACP — use createDraftSession", ); - const now = new Date().toISOString(); - const chatSession: ChatSession = { - id: backendSession.id, - title: opts?.title ?? backendSession.title, - projectId: opts?.projectId, - agentId: opts?.agentId ?? backendSession.agentId, - providerId: opts?.providerId, - personaId: opts?.personaId, - createdAt: backendSession.createdAt ?? now, - updatedAt: backendSession.updatedAt ?? now, - messageCount: backendSession.messageCount ?? 0, - }; - const initialUpdate: Record = {}; - if (opts?.title) initialUpdate.title = opts.title; - if (opts?.providerId) initialUpdate.providerId = opts.providerId; - if (opts?.personaId) initialUpdate.personaId = opts.personaId; - if (Object.keys(initialUpdate).length > 0) { - apiUpdateSession(backendSession.id, initialUpdate).catch((err) => { - console.error("Failed to persist initial session metadata:", err); - }); - } - set((state) => ({ - sessions: [...state.sessions, chatSession], - })); - persistSessions([...get().sessions]); - return chatSession; }, createDraftSession: (opts) => { @@ -214,11 +185,28 @@ export const useChatSessionStore = create((set, get) => ({ loadSessions: async () => { set({ isLoading: true }); try { - const backendSessions = await apiListSessions(); - const chatSessions = backendSessions.map(sessionToChatSession); + const acpSessions = await acpListSessions(); + const sessions: ChatSession[] = acpSessions.map((s) => ({ + id: s.sessionId, + title: s.title ?? "Untitled", + createdAt: s.updatedAt ?? new Date().toISOString(), + updatedAt: s.updatedAt ?? new Date().toISOString(), + messageCount: 0, + })); + // Sort by updatedAt descending (most recent first) + sessions.sort( + (a, b) => + new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + ); + // Preserve local drafts const drafts = get().sessions.filter((s) => s.draft); - set({ sessions: [...chatSessions, ...drafts] }); - persistSessions(chatSessions); + set({ sessions: [...sessions, ...drafts] }); + } catch (err) { + console.error("Failed to load sessions from ACP:", err); + // On error, at least load cached drafts + const cached = loadCachedSessions(); + const drafts = cached.filter((s) => s.draft); + set({ sessions: drafts }); } finally { set({ isLoading: false }); } @@ -227,78 +215,28 @@ export const useChatSessionStore = create((set, get) => ({ updateSession: (id, patch, opts) => { set((state) => ({ sessions: state.sessions.map((s) => - s.id === id ? { ...s, ...patch } : s, + s.id === id + ? { ...s, ...patch, updatedAt: new Date().toISOString() } + : s, ), })); persistSessions(get().sessions); if (opts?.localOnly) return; const session = get().sessions.find((s) => s.id === id); - if (session?.draft) return; // skip backend for drafts - const backendPatch: { - title?: string; - providerId?: string; - personaId?: string; - modelName?: string; - projectId?: string | null; - userSetName?: boolean; - } = {}; - if (patch.title) backendPatch.title = patch.title; - if (patch.providerId) backendPatch.providerId = patch.providerId; - if (patch.personaId) backendPatch.personaId = patch.personaId; - if (patch.modelName) backendPatch.modelName = patch.modelName; - if ("projectId" in patch) { - backendPatch.projectId = patch.projectId ?? null; - } - if ("userSetName" in patch) { - backendPatch.userSetName = patch.userSetName; - } - if (Object.keys(backendPatch).length > 0) { - apiUpdateSession(id, backendPatch).catch((err) => { - console.error("Failed to persist session update:", err); - }); - } + if (session?.draft) return; + // TODO: wire non-draft updates to ACP when supported }, archiveSession: async (id) => { - const previousActiveSessionId = get().activeSessionId; - set((state) => { - return { - activeSessionId: - state.activeSessionId === id ? null : state.activeSessionId, - }; - }); - - // Archive on backend — update local state on success - try { - await apiArchiveSession(id); - const archivedAt = new Date().toISOString(); - const nextSessions = get() - .sessions.map((s) => (s.id === id ? { ...s, archivedAt } : s)) - .filter((s) => !s.archivedAt); - const { [id]: _, ...remainingPanelState } = - get().contextPanelOpenBySession; - set({ - sessions: nextSessions, - contextPanelOpenBySession: remainingPanelState, - }); - persistSessions(nextSessions); - } catch (err) { - set({ - activeSessionId: previousActiveSessionId, - }); - console.error("Failed to archive session:", err); - throw err; - } + set((state) => ({ + sessions: state.sessions.filter((s) => s.id !== id), + activeSessionId: + state.activeSessionId === id ? null : state.activeSessionId, + })); }, - unarchiveSession: async (id) => { - try { - await apiUnarchiveSession(id); - await get().loadSessions(); - } catch (err) { - console.error("Failed to unarchive session:", err); - throw err; - } + unarchiveSession: async (_id) => { + // No-op — no archive persistence yet }, setActiveSession: (sessionId) => { diff --git a/ui/goose2/src/features/chat/stores/chatStore.ts b/ui/goose2/src/features/chat/stores/chatStore.ts index 05ff60df6c..84761e8dec 100644 --- a/ui/goose2/src/features/chat/stores/chatStore.ts +++ b/ui/goose2/src/features/chat/stores/chatStore.ts @@ -71,6 +71,9 @@ interface ChatStoreState { // Connection isConnected: boolean; + + // Sessions currently being loaded from history (replay in progress) + loadingSessionIds: Set; } interface ChatStoreActions { @@ -119,6 +122,9 @@ interface ChatStoreActions { setDraft: (sessionId: string, text: string) => void; clearDraft: (sessionId: string) => void; + // Session loading (replay) + setSessionLoading: (sessionId: string, loading: boolean) => void; + // Cleanup cleanupSession: (sessionId: string) => void; } @@ -133,6 +139,7 @@ export const useChatStore = create((set, get) => ({ draftsBySession: loadCachedDrafts(), activeSessionId: null, isConnected: false, + loadingSessionIds: new Set(), // Session management setActiveSession: (sessionId) => @@ -421,6 +428,18 @@ export const useChatStore = create((set, get) => ({ persistDrafts(get().draftsBySession); }, + // Session loading (replay) + setSessionLoading: (sessionId, loading) => + set((state) => { + const next = new Set(state.loadingSessionIds); + if (loading) { + next.add(sessionId); + } else { + next.delete(sessionId); + } + return { loadingSessionIds: next }; + }), + // Cleanup cleanupSession: (sessionId) => { set((state) => { diff --git a/ui/goose2/src/features/chat/ui/ChatLoadingSkeleton.tsx b/ui/goose2/src/features/chat/ui/ChatLoadingSkeleton.tsx new file mode 100644 index 0000000000..a889147a4c --- /dev/null +++ b/ui/goose2/src/features/chat/ui/ChatLoadingSkeleton.tsx @@ -0,0 +1,75 @@ +import { Skeleton } from "@/shared/ui/skeleton"; + +/** + * Placeholder skeleton shown while session history is being replayed + * from the backend. Mimics the visual rhythm of a real conversation + * with alternating user/assistant message shapes. + */ +export function ChatLoadingSkeleton() { + return ( +
+
+ {/* Date separator skeleton */} +
+
+ +
+
+ + {/* User message */} +
+
+ +
+
+ + {/* Assistant message — multi-line */} +
+
+ + +
+
+ + + +
+
+ + {/* User message */} +
+
+ +
+
+ + {/* Assistant message — shorter */} +
+
+ + +
+
+ + +
+
+ + {/* Tool call block */} +
+ +
+ + {/* Assistant continuation */} +
+ + +
+
+
+ ); +} diff --git a/ui/goose2/src/features/chat/ui/ChatView.tsx b/ui/goose2/src/features/chat/ui/ChatView.tsx index dcc9f12475..e830ba52ae 100644 --- a/ui/goose2/src/features/chat/ui/ChatView.tsx +++ b/ui/goose2/src/features/chat/ui/ChatView.tsx @@ -8,10 +8,10 @@ import { MessageTimeline } from "./MessageTimeline"; import { ChatInput } from "./ChatInput"; import type { PastedImage } from "@/shared/types/messages"; import { LoadingGoose } from "./LoadingGoose"; +import { ChatLoadingSkeleton } from "./ChatLoadingSkeleton"; import { ContextPanel } from "./ContextPanel"; import { useChat } from "../hooks/useChat"; import { useMessageQueue } from "../hooks/useMessageQueue"; -import { useSessionAutoTitle } from "@/features/sessions/hooks/useSessionAutoTitle"; import { useChatStore } from "../stores/chatStore"; import { useAgentStore } from "@/features/agents/stores/agentStore"; import { useProviderSelection } from "@/features/agents/hooks/useProviderSelection"; @@ -30,7 +30,7 @@ import { Button } from "@/shared/ui/button"; import { ArtifactPolicyProvider } from "../hooks/ArtifactPolicyContext"; interface ChatViewProps { - sessionId?: string; + sessionId: string; agentName?: string; agentAvatarUrl?: string; initialProvider?: string; @@ -61,7 +61,7 @@ export function ChatView({ onInitialMessageConsumed, onCreateProject, }: ChatViewProps) { - const [activeSessionId] = useState(() => sessionId ?? crypto.randomUUID()); + const activeSessionId = sessionId; const isContextPanelOpen = useChatSessionStore( (s) => s.contextPanelOpenBySession[activeSessionId] ?? false, ); @@ -76,7 +76,6 @@ export function ChatView({ selectedProvider: globalSelectedProvider, setSelectedProvider: setGlobalSelectedProvider, } = useProviderSelection(); - const personas = useAgentStore((s) => s.personas); const [selectedPersonaId, setSelectedPersonaId] = useState( initialPersonaId ?? null, @@ -143,7 +142,6 @@ export function ChatView({ useEffect(() => { let cancelled = false; - if (!session?.projectId || storedProject) { setFallbackProject(null); return; @@ -187,7 +185,6 @@ export function ChatView({ if (providerId === selectedProvider) { return; } - setGlobalSelectedProvider(providerId); useChatSessionStore .getState() @@ -259,7 +256,6 @@ export function ChatView({ useEffect(() => { let cancelled = false; - acpPrepareSession(activeSessionId, selectedProvider, { workingDir: effectiveWorkingDir, personaId: selectedPersonaId ?? undefined, @@ -289,8 +285,11 @@ export function ChatView({ personaInfo, effectiveWorkingDir, ); - - useSessionAutoTitle(activeSessionId); + const isLoadingHistory = useChatStore( + (s) => + s.loadingSessionIds.has(activeSessionId) && + (s.messagesBySession[activeSessionId]?.length ?? 0) === 0, + ); const deferredSend = useRef<{ text: string; images?: PastedImage[] } | null>( null, @@ -385,14 +384,18 @@ export function ChatView({ >
- + {isLoadingHistory ? ( + + ) : ( + + )} - {showIndicator && ( + {showIndicator && !isLoadingHistory && ( { + it("renders with loading status role", () => { + render(); + + expect(screen.getByRole("status")).toBeInTheDocument(); + }); + + it("has accessible label for screen readers", () => { + render(); + + expect( + screen.getByRole("status", { name: /loading conversation/i }), + ).toBeInTheDocument(); + }); + + it("renders skeleton elements", () => { + const { container } = render(); + + const skeletons = container.querySelectorAll('[class*="animate-pulse"]'); + expect(skeletons.length).toBeGreaterThan(0); + }); +}); diff --git a/ui/goose2/src/features/sessions/hooks/__tests__/useSessionAutoTitle.test.ts b/ui/goose2/src/features/sessions/hooks/__tests__/useSessionAutoTitle.test.ts deleted file mode 100644 index 5cf7eca6ad..0000000000 --- a/ui/goose2/src/features/sessions/hooks/__tests__/useSessionAutoTitle.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { renderHook } from "@testing-library/react"; -import { useChatStore } from "@/features/chat/stores/chatStore"; -import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; - -// Mock the API module — include updateSession since chatSessionStore imports it -vi.mock("@/shared/api/chat", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - generateSessionTitle: vi.fn(), - updateSession: vi.fn().mockResolvedValue(undefined), - }; -}); - -import { generateSessionTitle } from "@/shared/api/chat"; -import { useSessionAutoTitle } from "../useSessionAutoTitle"; - -const mockGenerateTitle = vi.mocked(generateSessionTitle); - -function seedSession( - id: string, - overrides: Partial<{ - title: string; - userSetName: boolean; - draft: boolean; - messageCount: number; - }> = {}, -) { - const now = new Date().toISOString(); - useChatSessionStore.setState({ - sessions: [ - { - id, - title: overrides.title ?? "New Chat", - createdAt: now, - updatedAt: now, - messageCount: overrides.messageCount ?? 0, - draft: overrides.draft, - userSetName: overrides.userSetName, - }, - ], - }); -} - -describe("useSessionAutoTitle", () => { - beforeEach(() => { - vi.clearAllMocks(); - useChatStore.setState({ - messagesBySession: {}, - sessionStateById: {}, - draftsBySession: {}, - activeSessionId: null, - isConnected: false, - }); - useChatSessionStore.setState({ - sessions: [], - activeSessionId: null, - isLoading: false, - contextPanelOpenBySession: {}, - }); - }); - - it("does not trigger when session has userSetName", () => { - seedSession("s1", { userSetName: true, messageCount: 2 }); - useChatStore.getState().setChatState("s1", "idle"); - - renderHook(() => useSessionAutoTitle("s1")); - - expect(mockGenerateTitle).not.toHaveBeenCalled(); - }); - - it("does not trigger when messageCount is not 2", () => { - seedSession("s1", { messageCount: 0 }); - useChatStore.getState().setChatState("s1", "idle"); - - renderHook(() => useSessionAutoTitle("s1")); - - expect(mockGenerateTitle).not.toHaveBeenCalled(); - }); - - it("generates title when first exchange completes", async () => { - seedSession("s1", { messageCount: 2 }); - mockGenerateTitle.mockResolvedValue("Fix sidebar resize bug"); - - // Start in streaming state - useChatStore.getState().setChatState("s1", "streaming"); - - const { rerender } = renderHook(() => useSessionAutoTitle("s1")); - - // Transition to idle (stream completed) - useChatStore.getState().setChatState("s1", "idle"); - rerender(); - - // Wait for the async call - await vi.waitFor(() => { - expect(mockGenerateTitle).toHaveBeenCalledWith("s1"); - }); - }); - - it("uses fallback title on API failure", async () => { - seedSession("s1", { messageCount: 2 }); - mockGenerateTitle.mockRejectedValue(new Error("LLM unavailable")); - - // Add a user message so fallback can extract text - useChatStore.getState().addMessage("s1", { - id: "msg-1", - role: "user", - created: Date.now(), - content: [ - { - type: "text", - text: "Help me fix the authentication middleware in the login flow", - }, - ], - metadata: { userVisible: true }, - }); - - useChatStore.getState().setChatState("s1", "streaming"); - - const { rerender } = renderHook(() => useSessionAutoTitle("s1")); - - useChatStore.getState().setChatState("s1", "idle"); - rerender(); - - // Wait for the rejection to be handled and fallback title to be set - await vi.waitFor(() => { - expect(mockGenerateTitle).toHaveBeenCalledWith("s1"); - // Should have updated session with fallback (first 50 chars of user message) - const session = useChatSessionStore.getState().getSession("s1"); - expect(session?.title).toBe( - "Help me fix the authentication middleware in the l", - ); - }); - }); -}); diff --git a/ui/goose2/src/features/sessions/hooks/useSessionAutoTitle.ts b/ui/goose2/src/features/sessions/hooks/useSessionAutoTitle.ts deleted file mode 100644 index 2847b0feea..0000000000 --- a/ui/goose2/src/features/sessions/hooks/useSessionAutoTitle.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { useEffect, useRef } from "react"; -import { useChatStore } from "@/features/chat/stores/chatStore"; -import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; -import { generateSessionTitle } from "@/shared/api/chat"; - -/** - * Automatically generates a session title after the first exchange completes. - * Watches for the transition from streaming → idle when messageCount === 2. - * Skips sessions where the user has manually set a title. - */ -export function useSessionAutoTitle(sessionId: string) { - const firedRef = useRef(false); - const prevChatStateRef = useRef(null); - - const chatState = useChatStore( - (s) => s.getSessionRuntime(sessionId).chatState, - ); - const session = useChatSessionStore((s) => - s.sessions.find((candidate) => candidate.id === sessionId), - ); - - useEffect(() => { - if (firedRef.current) return; - if (!session || session.userSetName) return; - if (session.messageCount !== 2) { - prevChatStateRef.current = chatState; - return; - } - - const wasStreaming = prevChatStateRef.current === "streaming"; - prevChatStateRef.current = chatState; - - if (chatState !== "idle" || !wasStreaming) return; - - firedRef.current = true; - - generateSessionTitle(sessionId) - .then((title) => { - useChatSessionStore - .getState() - .updateSession(sessionId, { title, userSetName: false }); - }) - .catch(() => { - // Fallback: use first 50 chars of first user message - const messages = - useChatStore.getState().messagesBySession[sessionId] ?? []; - const firstUserMessage = messages.find((m) => m.role === "user"); - const textBlock = firstUserMessage?.content.find( - (c) => c.type === "text", - ); - if (textBlock && "text" in textBlock) { - const fallback = textBlock.text.trim().slice(0, 50); - useChatSessionStore - .getState() - .updateSession(sessionId, { title: fallback, userSetName: false }); - } - }); - }, [sessionId, chatState, session]); -} diff --git a/ui/goose2/src/features/sessions/ui/SessionHistoryView.tsx b/ui/goose2/src/features/sessions/ui/SessionHistoryView.tsx index ba4e9276db..02dca614a3 100644 --- a/ui/goose2/src/features/sessions/ui/SessionHistoryView.tsx +++ b/ui/goose2/src/features/sessions/ui/SessionHistoryView.tsx @@ -8,7 +8,6 @@ import { filterSessions } from "../lib/filterSessions"; import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; import { useAgentStore } from "@/features/agents/stores/agentStore"; import { useProjectStore } from "@/features/projects/stores/projectStore"; -import { listArchivedSessions } from "@/shared/api/chat"; import type { ChatSession } from "@/features/chat/stores/chatSessionStore"; interface SessionHistoryViewProps { @@ -32,33 +31,9 @@ export function SessionHistoryView({ const [search, setSearch] = useState(""); const loadArchived = useCallback(() => { - let cancelled = false; - listArchivedSessions() - .then((result) => { - if (cancelled) return; - setArchivedSessions( - result.map((s) => ({ - id: s.id, - title: s.title, - projectId: s.projectId, - agentId: s.agentId, - providerId: s.providerId, - personaId: s.personaId, - modelName: s.modelName, - createdAt: s.createdAt, - updatedAt: s.updatedAt, - archivedAt: s.archivedAt, - messageCount: s.messageCount, - userSetName: s.userSetName, - })), - ); - }) - .catch(() => { - if (!cancelled) setArchivedSessions([]); - }); - return () => { - cancelled = true; - }; + // TODO: Wire to ACP when archived sessions are supported + // For now, archived sessions aren't persisted to the backend + setArchivedSessions([]); }, []); useEffect(loadArchived, [loadArchived]); diff --git a/ui/goose2/src/features/settings/ui/SettingsModal.tsx b/ui/goose2/src/features/settings/ui/SettingsModal.tsx index 0be3cb4815..7b13a3863b 100644 --- a/ui/goose2/src/features/settings/ui/SettingsModal.tsx +++ b/ui/goose2/src/features/settings/ui/SettingsModal.tsx @@ -30,7 +30,7 @@ import { } from "@/features/projects/api/projects"; import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; import { useProjectStore } from "@/features/projects/stores/projectStore"; -import { listArchivedSessions } from "@/shared/api/chat"; + import type { Session } from "@/shared/types/chat"; const NAV_ITEMS = [ @@ -74,12 +74,10 @@ export function SettingsModal({ onClose }: SettingsModalProps) { .finally(() => setLoadingArchived(false)); }, []); - // Load archived chats on mount + // TODO: wire to ACP for archived session listing useEffect(() => { - listArchivedSessions() - .then(setArchivedChats) - .catch(() => setArchivedChats([])) - .finally(() => setLoadingArchivedChats(false)); + setArchivedChats([]); + setLoadingArchivedChats(false); }, []); const handleRestoreProject = async (id: string) => { diff --git a/ui/goose2/src/shared/api/acp.ts b/ui/goose2/src/shared/api/acp.ts index 1bda1bca9d..579d955f2b 100644 --- a/ui/goose2/src/shared/api/acp.ts +++ b/ui/goose2/src/shared/api/acp.ts @@ -59,6 +59,36 @@ export async function acpPrepareSession( }); } +/** Session info returned by the goose binary's list_sessions. */ +export interface AcpSessionInfo { + sessionId: string; + title: string | null; + updatedAt: string | null; +} + +/** List all sessions known to the goose binary. */ +export async function acpListSessions(): Promise { + return invoke("acp_list_sessions"); +} + +/** + * Load an existing session from the goose binary. + * + * This triggers message replay via SessionNotification events that the + * frontend's useAcpStream hook picks up automatically. + */ +export async function acpLoadSession( + sessionId: string, + gooseSessionId: string, + workingDir?: string, +): Promise { + return invoke("acp_load_session", { + sessionId, + gooseSessionId, + workingDir: workingDir ?? null, + }); +} + /** Cancel an in-progress ACP session so the backend stops streaming. */ export async function acpCancelSession( sessionId: string, diff --git a/ui/goose2/src/shared/api/chat.ts b/ui/goose2/src/shared/api/chat.ts deleted file mode 100644 index 69ac445d4b..0000000000 --- a/ui/goose2/src/shared/api/chat.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { invoke } from "@tauri-apps/api/core"; -import type { Message } from "@/shared/types/messages"; -import type { Session } from "@/shared/types/chat"; - -export async function createSession( - agentId?: string, - projectId?: string, -): Promise { - return invoke("create_session", { agentId, projectId }); -} - -export async function listSessions(): Promise { - return invoke("list_sessions"); -} - -export async function listArchivedSessions(): Promise { - return invoke("list_archived_sessions"); -} - -export async function getSessionMessages( - sessionId: string, -): Promise { - return invoke("get_session_messages", { sessionId }); -} - -export async function sendMessage( - sessionId: string, - message: Message, -): Promise { - return invoke("chat_send_message", { sessionId, message }); -} - -export async function updateSession( - sessionId: string, - update: { - title?: string; - providerId?: string; - personaId?: string; - modelName?: string; - projectId?: string | null; - userSetName?: boolean; - }, -): Promise { - return invoke("update_session", { sessionId, update }); -} - -export async function deleteSession(sessionId: string): Promise { - return invoke("delete_session", { sessionId }); -} - -export async function archiveSession(sessionId: string): Promise { - return invoke("archive_session", { sessionId }); -} - -export async function unarchiveSession(sessionId: string): Promise { - return invoke("unarchive_session", { sessionId }); -} - -export async function generateSessionTitle(sessionId: string): Promise { - return invoke("generate_session_title", { sessionId }); -} diff --git a/ui/goose2/src/shared/api/index.ts b/ui/goose2/src/shared/api/index.ts index 4663855ca6..582c1cfed2 100644 --- a/ui/goose2/src/shared/api/index.ts +++ b/ui/goose2/src/shared/api/index.ts @@ -1,4 +1,3 @@ -export * from "./chat"; export * from "./agents"; export * from "./acp"; export * from "./git"; diff --git a/ui/goose2/src/shared/types/__tests__/messages.test.ts b/ui/goose2/src/shared/types/__tests__/messages.test.ts index 04a98293f4..7af17b86fc 100644 --- a/ui/goose2/src/shared/types/__tests__/messages.test.ts +++ b/ui/goose2/src/shared/types/__tests__/messages.test.ts @@ -153,7 +153,10 @@ describe("createUserMessage", () => { expect(msg.created).toBeGreaterThan(0); expect(msg.content).toHaveLength(1); expect(msg.content[0]).toEqual({ type: "text", text: "hi there" }); - expect(msg.metadata).toBeUndefined(); + expect(msg.metadata).toEqual({ + userVisible: true, + agentVisible: true, + }); }); it("includes attachments when provided", () => { @@ -162,6 +165,8 @@ describe("createUserMessage", () => { ]; const msg = createUserMessage("check this", attachments); expect(msg.metadata).toBeDefined(); + expect(msg.metadata?.userVisible).toBe(true); + expect(msg.metadata?.agentVisible).toBe(true); expect(msg.metadata?.attachments).toEqual(attachments); }); }); diff --git a/ui/goose2/src/shared/types/messages.ts b/ui/goose2/src/shared/types/messages.ts index a6fe49bf8f..e31cf1adf2 100644 --- a/ui/goose2/src/shared/types/messages.ts +++ b/ui/goose2/src/shared/types/messages.ts @@ -168,7 +168,11 @@ export function createUserMessage( role: "user", created: Date.now(), content: [{ type: "text", text }], - metadata: attachments ? { attachments } : undefined, + metadata: { + userVisible: true, + agentVisible: true, + ...(attachments ? { attachments } : {}), + }, }; }