diff --git a/crates/goose-cli/src/session/builder.rs b/crates/goose-cli/src/session/builder.rs index d875f8ba..3eb8d885 100644 --- a/crates/goose-cli/src/session/builder.rs +++ b/crates/goose-cli/src/session/builder.rs @@ -35,7 +35,59 @@ pub async fn build_session( let mut agent = AgentFactory::create(&AgentFactory::configured_version(), provider) .expect("Failed to create agent"); + // Handle session file resolution and resuming + let session_file = if resume { + if let Some(identifier) = identifier { + let session_file = session::get_path(identifier); + if !session_file.exists() { + output::render_error(&format!( + "Cannot resume session {} - no such session exists", + style(session_file.display()).cyan() + )); + process::exit(1); + } + + session_file + } else { + // Try to resume most recent session + match session::get_most_recent_session() { + Ok(file) => file, + Err(_) => { + output::render_error("Cannot resume - no previous sessions found"); + process::exit(1); + } + } + } + } else { + // Create new session with provided name/path or generated name + let id = match identifier { + Some(identifier) => identifier, + None => Identifier::Name(session::generate_session_id()), + }; + + // Just get the path - file will be created when needed + session::get_path(id) + }; + + if resume { + // Read the session metadata + let metadata = session::read_metadata(&session_file).unwrap_or_else(|e| { + output::render_error(&format!("Failed to read session metadata: {}", e)); + process::exit(1); + }); + + // Ask user if they want to change the working directory + let change_workdir = cliclack::confirm(format!("{} The working directory of this session was set to {}. It does not match the current working directory. Would you like to change it?", style("WARNING:").yellow(), style(metadata.working_dir.display()).cyan())) + .initial_value(true) + .interact().expect("Failed to get user input"); + + if change_workdir { + std::env::set_current_dir(metadata.working_dir).unwrap(); + } + } + // Setup extensions for the agent + // Extensions need to be added after the session is created because we change directory when resuming a session for extension in ExtensionManager::get_all().expect("should load extensions") { if extension.enabled { let config = extension.config.clone(); @@ -59,39 +111,6 @@ pub async fn build_session( } } - // Handle session file resolution and resuming - let session_file = if resume { - if let Some(identifier) = identifier { - let session_file = session::get_path(identifier); - if !session_file.exists() { - output::render_error(&format!( - "Cannot resume session {} - no such session exists", - style(session_file.display()).cyan() - )); - process::exit(1); - } - session_file - } else { - // Try to resume most recent session - match session::get_most_recent_session() { - Ok(file) => file, - Err(_) => { - output::render_error("Cannot resume - no previous sessions found"); - process::exit(1); - } - } - } - } else { - // Create new session with provided name/path or generated name - let id = match identifier { - Some(identifier) => identifier, - None => Identifier::Name(session::generate_session_id()), - }; - - // Just get the path - file will be created when needed - session::get_path(id) - }; - // Create new session let mut session = Session::new(agent, session_file.clone(), debug); diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 6af4287d..f4bcb9f4 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -13,7 +13,7 @@ use completion::GooseCompleter; use etcetera::choose_app_strategy; use etcetera::AppStrategy; use goose::agents::extension::{Envs, ExtensionConfig}; -use goose::agents::Agent; +use goose::agents::{Agent, SessionConfig}; use goose::config::Config; use goose::message::{Message, MessageContent}; use goose::session; @@ -199,7 +199,6 @@ impl Session { /// Process a single message and get the response async fn process_message(&mut self, message: String) -> Result<()> { self.messages.push(Message::user().with_text(&message)); - // Get the provider from the agent for description generation let provider = self.agent.provider().await; @@ -436,7 +435,17 @@ impl Session { async fn process_agent_response(&mut self, interactive: bool) -> Result<()> { let session_id = session::Identifier::Path(self.session_file.clone()); - let mut stream = self.agent.reply(&self.messages, Some(session_id)).await?; + let mut stream = self + .agent + .reply( + &self.messages, + Some(SessionConfig { + id: session_id, + working_dir: std::env::current_dir() + .expect("failed to get current session working directory"), + }), + ) + .await?; use futures::StreamExt; loop { diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index 3adbbeb9..4a7e82a0 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -479,6 +479,13 @@ pub fn display_session_info(resume: bool, provider: &str, model: &str, session_f style("logging to").dim(), style(session_file.display()).dim().cyan(), ); + println!( + " {} {}", + style("working directory:").dim(), + style(std::env::current_dir().unwrap().display()) + .cyan() + .dim() + ); } pub fn display_greeting() { diff --git a/crates/goose-server/src/routes/reply.rs b/crates/goose-server/src/routes/reply.rs index b7a9e4a3..9a2271d0 100644 --- a/crates/goose-server/src/routes/reply.rs +++ b/crates/goose-server/src/routes/reply.rs @@ -8,14 +8,18 @@ use axum::{ }; use bytes::Bytes; use futures::{stream::StreamExt, Stream}; -use goose::message::{Message, MessageContent}; use goose::session; +use goose::{ + agents::SessionConfig, + message::{Message, MessageContent}, +}; use mcp_core::role::Role; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::{ convert::Infallible, + path::PathBuf, pin::Pin, task::{Context, Poll}, time::Duration, @@ -29,6 +33,7 @@ use tokio_stream::wrappers::ReceiverStream; struct ChatRequest { messages: Vec, session_id: Option, + session_working_dir: String, } // Custom SSE response type for streaming messages @@ -108,8 +113,8 @@ async fn handler( let (tx, rx) = mpsc::channel(100); let stream = ReceiverStream::new(rx); - // Get messages directly from the request let messages = request.messages; + let session_working_dir = request.session_working_dir; // Generate a new session ID if not provided in the request let session_id = request @@ -149,7 +154,10 @@ async fn handler( let mut stream = match agent .reply( &messages, - Some(session::Identifier::Name(session_id.clone())), + Some(SessionConfig { + id: session::Identifier::Name(session_id.clone()), + working_dir: PathBuf::from(session_working_dir), + }), ) .await { @@ -246,6 +254,7 @@ async fn handler( struct AskRequest { prompt: String, session_id: Option, + session_working_dir: String, } #[derive(Debug, Serialize)] @@ -269,6 +278,8 @@ async fn ask_handler( return Err(StatusCode::UNAUTHORIZED); } + let session_working_dir = request.session_working_dir; + // Generate a new session ID if not provided in the request let session_id = request .session_id @@ -289,7 +300,10 @@ async fn ask_handler( let mut stream = match agent .reply( &messages, - Some(session::Identifier::Name(session_id.clone())), + Some(SessionConfig { + id: session::Identifier::Name(session_id.clone()), + working_dir: PathBuf::from(session_working_dir), + }), ) .await { @@ -464,6 +478,7 @@ mod tests { serde_json::to_string(&AskRequest { prompt: "test prompt".to_string(), session_id: Some("test-session".to_string()), + session_working_dir: "test-working-dir".to_string(), }) .unwrap(), )) diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index e473e6a5..d724fb4a 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -1,8 +1,10 @@ use std::collections::HashMap; +use std::path::PathBuf; use anyhow::Result; use async_trait::async_trait; use futures::stream::BoxStream; +use serde::{Deserialize, Serialize}; use serde_json::Value; use std::sync::Arc; @@ -13,6 +15,15 @@ use crate::session; use mcp_core::prompt::Prompt; use mcp_core::protocol::GetPromptResult; +/// Session configuration for an agent +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionConfig { + /// Unique identifier for the session + pub id: session::Identifier, + /// Working directory for the session + pub working_dir: PathBuf, +} + /// Core trait defining the behavior of an Agent #[async_trait] pub trait Agent: Send + Sync { @@ -20,7 +31,7 @@ pub trait Agent: Send + Sync { async fn reply( &self, messages: &[Message], - session_id: Option, + session: Option, ) -> Result>>; /// Add a new MCP client to the agent diff --git a/crates/goose/src/agents/mod.rs b/crates/goose/src/agents/mod.rs index 36529b56..ceac2916 100644 --- a/crates/goose/src/agents/mod.rs +++ b/crates/goose/src/agents/mod.rs @@ -7,7 +7,7 @@ mod reference; mod summarize; mod truncate; -pub use agent::Agent; +pub use agent::{Agent, SessionConfig}; pub use capabilities::Capabilities; pub use extension::ExtensionConfig; pub use factory::{register_agent, AgentFactory}; diff --git a/crates/goose/src/agents/reference.rs b/crates/goose/src/agents/reference.rs index 0ce6fdc3..089202de 100644 --- a/crates/goose/src/agents/reference.rs +++ b/crates/goose/src/agents/reference.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use tokio::sync::Mutex; use tracing::{debug, instrument}; +use super::agent::SessionConfig; use super::Agent; use crate::agents::capabilities::Capabilities; use crate::agents::extension::{ExtensionConfig, ExtensionResult}; @@ -70,11 +71,11 @@ impl Agent for ReferenceAgent { // TODO implement } - #[instrument(skip(self, messages), fields(user_message))] + #[instrument(skip(self, messages, session), fields(user_message))] async fn reply( &self, messages: &[Message], - session_id: Option, + session: Option, ) -> anyhow::Result>> { let mut messages = messages.to_vec(); let reply_span = tracing::Span::current(); @@ -148,10 +149,11 @@ impl Agent for ReferenceAgent { capabilities.record_usage(usage.clone()).await; // record usage for the session in the session file - if let Some(session_id) = session_id.clone() { + if let Some(session) = session.clone() { // TODO: track session_id in langfuse tracing - let session_file = session::get_path(session_id); + let session_file = session::get_path(session.id); let mut metadata = session::read_metadata(&session_file)?; + metadata.working_dir = session.working_dir; metadata.total_tokens = usage.usage.total_tokens; // The message count is the number of messages in the session + 1 for the response // The message count does not include the tool response till next iteration diff --git a/crates/goose/src/agents/summarize.rs b/crates/goose/src/agents/summarize.rs index 6be0f1d1..a2f93902 100644 --- a/crates/goose/src/agents/summarize.rs +++ b/crates/goose/src/agents/summarize.rs @@ -9,6 +9,7 @@ use tokio::sync::mpsc; use tokio::sync::Mutex; use tracing::{debug, error, instrument, warn}; +use super::agent::SessionConfig; use super::detect_read_only_tools; use super::Agent; use crate::agents::capabilities::Capabilities; @@ -162,11 +163,11 @@ impl Agent for SummarizeAgent { } } - #[instrument(skip(self, messages), fields(user_message))] + #[instrument(skip(self, messages, session), fields(user_message))] async fn reply( &self, messages: &[Message], - session_id: Option, + session: Option, ) -> anyhow::Result>> { let mut messages = messages.to_vec(); let reply_span = tracing::Span::current(); @@ -246,10 +247,11 @@ impl Agent for SummarizeAgent { capabilities.record_usage(usage.clone()).await; // record usage for the session in the session file - if let Some(session_id) = session_id.clone() { + if let Some(session) = session.clone() { // TODO: track session_id in langfuse tracing - let session_file = session::get_path(session_id); + let session_file = session::get_path(session.id); let mut metadata = session::read_metadata(&session_file)?; + metadata.working_dir = session.working_dir; metadata.total_tokens = usage.usage.total_tokens; // The message count is the number of messages in the session + 1 for the response // The message count does not include the tool response till next iteration diff --git a/crates/goose/src/agents/truncate.rs b/crates/goose/src/agents/truncate.rs index fc3414f8..c0cbd3ae 100644 --- a/crates/goose/src/agents/truncate.rs +++ b/crates/goose/src/agents/truncate.rs @@ -8,6 +8,7 @@ use tokio::sync::mpsc; use tokio::sync::Mutex; use tracing::{debug, error, instrument, warn}; +use super::agent::SessionConfig; use super::detect_read_only_tools; use super::Agent; use crate::agents::capabilities::Capabilities; @@ -145,11 +146,11 @@ impl Agent for TruncateAgent { } } - #[instrument(skip(self, messages, session_id), fields(user_message))] + #[instrument(skip(self, messages, session), fields(user_message))] async fn reply( &self, messages: &[Message], - session_id: Option, + session: Option, ) -> anyhow::Result>> { let mut messages = messages.to_vec(); let reply_span = tracing::Span::current(); @@ -229,10 +230,11 @@ impl Agent for TruncateAgent { capabilities.record_usage(usage.clone()).await; // record usage for the session in the session file - if let Some(session_id) = session_id.clone() { + if let Some(session) = session.clone() { // TODO: track session_id in langfuse tracing - let session_file = session::get_path(session_id); + let session_file = session::get_path(session.id); let mut metadata = session::read_metadata(&session_file)?; + metadata.working_dir = session.working_dir; metadata.total_tokens = usage.usage.total_tokens; // The message count is the number of messages in the session + 1 for the response // The message count does not include the tool response till next iteration diff --git a/crates/goose/src/session/storage.rs b/crates/goose/src/session/storage.rs index 6af86cbf..4bff61d8 100644 --- a/crates/goose/src/session/storage.rs +++ b/crates/goose/src/session/storage.rs @@ -9,9 +9,18 @@ use std::io::{self, BufRead, Write}; use std::path::{Path, PathBuf}; use std::sync::Arc; +fn get_home_dir() -> PathBuf { + choose_app_strategy(crate::config::APP_STRATEGY.clone()) + .expect("goose requires a home dir") + .home_dir() + .to_path_buf() +} + /// Metadata for a session, stored as the first line in the session file -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct SessionMetadata { + /// Working directory for the session + pub working_dir: PathBuf, /// A short description of the session, typically 3 words or less pub description: String, /// Number of messages in the session @@ -20,9 +29,35 @@ pub struct SessionMetadata { pub total_tokens: Option, } +// Custom deserializer to handle old sessions without working_dir +impl<'de> Deserialize<'de> for SessionMetadata { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct Helper { + description: String, + message_count: usize, + total_tokens: Option, + working_dir: Option, + } + + let helper = Helper::deserialize(deserializer)?; + + Ok(SessionMetadata { + description: helper.description, + message_count: helper.message_count, + total_tokens: helper.total_tokens, + working_dir: helper.working_dir.unwrap_or_else(get_home_dir), + }) + } +} + impl SessionMetadata { - pub fn new() -> Self { + pub fn new(working_dir: PathBuf) -> Self { Self { + working_dir, description: String::new(), message_count: 0, total_tokens: None, @@ -32,7 +67,7 @@ impl SessionMetadata { impl Default for SessionMetadata { fn default() -> Self { - Self::new() + Self::new(get_home_dir()) } } @@ -168,7 +203,7 @@ pub fn read_messages(session_file: &Path) -> Result> { /// Returns default empty metadata if the file doesn't exist or has no metadata. pub fn read_metadata(session_file: &Path) -> Result { if !session_file.exists() { - return Ok(SessionMetadata::new()); + return Ok(SessionMetadata::default()); } let file = fs::File::open(session_file)?; @@ -182,12 +217,12 @@ pub fn read_metadata(session_file: &Path) -> Result { Ok(metadata) => Ok(metadata), Err(_) => { // If the first line isn't metadata, return default - Ok(SessionMetadata::new()) + Ok(SessionMetadata::default()) } } } else { // Empty file, return default - Ok(SessionMetadata::new()) + Ok(SessionMetadata::default()) } } diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 12cc3439..f543901b 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -11,6 +11,7 @@ import { ConfirmationModal } from './components/ui/ConfirmationModal'; import { ToastContainer } from 'react-toastify'; import { extractExtensionName } from './components/settings/extensions/utils'; import { GoosehintsModal } from './components/GoosehintsModal'; +import { SessionDetails, fetchSessionDetails } from './sessions'; import WelcomeView from './components/WelcomeView'; import ChatView from './components/ChatView'; @@ -37,7 +38,12 @@ export type View = export type ViewConfig = { view: View; - viewOptions?: SettingsViewOptions | Record; + viewOptions?: + | SettingsViewOptions + | { + resumedSession?: SessionDetails; + } + | Record; }; export default function App() { @@ -51,6 +57,7 @@ export default function App() { viewOptions: {}, }); const [isGoosehintsModalOpen, setIsGoosehintsModalOpen] = useState(false); + const [isLoadingSession, setIsLoadingSession] = useState(false); const { switchModel } = useModel(); const { addRecentModel } = useRecentModels(); @@ -135,6 +142,7 @@ export default function App() { addRecentModel(model); } } catch (error) { + // TODO: add sessionError state and show error screen with option to start fresh console.error('Failed to initialize with stored provider:', error); } } @@ -143,6 +151,39 @@ export default function App() { setupStoredProvider(); }, []); + // Check for resumeSessionId in URL parameters + useEffect(() => { + const checkForResumeSession = async () => { + const urlParams = new URLSearchParams(window.location.search); + const resumeSessionId = urlParams.get('resumeSessionId'); + + if (!resumeSessionId) { + return; + } + + setIsLoadingSession(true); + try { + const sessionDetails = await fetchSessionDetails(resumeSessionId); + + // Only set view if we have valid session details + if (sessionDetails && sessionDetails.session_id) { + setView('chat', { + resumedSession: sessionDetails, + }); + } else { + console.error('Invalid session details received'); + } + } catch (error) { + console.error('Failed to fetch session details:', error); + } finally { + // Always clear the loading state + setIsLoadingSession(false); + } + }; + + checkForResumeSession(); + }, []); + useEffect(() => { const handleFatalError = (_: any, errorMessage: string) => { setFatalError(errorMessage); @@ -160,6 +201,13 @@ export default function App() { return () => window.electron.off('set-view', handleSetView); }, []); + // Add cleanup for session states when view changes + useEffect(() => { + if (view !== 'chat') { + setIsLoadingSession(false); + } + }, [view]); + const handleConfirm = async () => { if (pendingLink && !isInstalling) { setIsInstalling(true); @@ -250,13 +298,18 @@ export default function App() { {view === 'alphaConfigureProviders' && ( setView('chat')} /> )} - {view === 'chat' && ( + {view === 'chat' && !isLoadingSession && ( )} + {view === 'chat' && isLoadingSession && ( +
+
+
+ )} {view === 'sessions' && } diff --git a/ui/desktop/src/components/ChatView.tsx b/ui/desktop/src/components/ChatView.tsx index e3e789e4..096e214b 100644 --- a/ui/desktop/src/components/ChatView.tsx +++ b/ui/desktop/src/components/ChatView.tsx @@ -48,63 +48,28 @@ export default function ChatView({ const resumedSession = viewOptions?.resumedSession; // Generate or retrieve session ID - const [sessionId] = useState(() => { - // If resuming a session, use that session ID - if (resumedSession?.session_id) { - // Store the resumed session ID in sessionStorage - window.sessionStorage.setItem('goose-session-id', resumedSession.session_id); - return resumedSession.session_id; - } - - // For a new chat, generate a new session ID - const newId = generateSessionId(); - window.sessionStorage.setItem('goose-session-id', newId); - return newId; - }); + // The session ID should not change for the duration of the chat + const sessionId = resumedSession?.session_id || generateSessionId(); const [chat, setChat] = useState(() => { // If resuming a session, convert the session messages to our format if (resumedSession) { - try { - // Convert the resumed session messages to the expected format - const convertedMessages = resumedSession.messages.map((msg): Message => { - return { - id: `${msg.role}-${msg.created}`, - role: msg.role, - created: msg.created, - content: msg.content, - }; - }); - - return { - id: Date.now(), - title: resumedSession.metadata?.description || `ID: ${resumedSession.session_id}`, - messageHistoryIndex: convertedMessages.length, - messages: convertedMessages, - }; - } catch (e) { - console.error('Failed to parse resumed session:', e); - } + return { + id: resumedSession.session_id, + title: resumedSession.metadata?.description || `ID: ${resumedSession.session_id}`, + messages: resumedSession.messages, + messageHistoryIndex: resumedSession.messages.length, + }; } - // Try to load saved chat from sessionStorage - const savedChat = window.sessionStorage.getItem(`goose-chat-${sessionId}`); - if (savedChat) { - try { - return JSON.parse(savedChat); - } catch (e) { - console.error('Failed to parse saved chat:', e); - } - } - - // Return default chat if no saved chat exists return { - id: Date.now(), - title: 'Chat 1', + id: sessionId, + title: 'New Chat', messages: [], messageHistoryIndex: 0, }; }); + const [messageMetadata, setMessageMetadata] = useState>({}); const [hasMessages, setHasMessages] = useState(false); const [lastInteractionTime, setLastInteractionTime] = useState(Date.now()); @@ -124,8 +89,8 @@ export default function ChatView({ handleSubmit: _submitMessage, } = useMessageStream({ api: getApiUrl('/reply'), - initialMessages: chat?.messages || [], - body: { session_id: sessionId }, + initialMessages: resumedSession ? resumedSession.messages : chat?.messages || [], + body: { session_id: sessionId, session_working_dir: window.appConfig.get('GOOSE_WORKING_DIR') }, onFinish: async (message, _reason) => { window.electron.stopPowerSaveBlocker(); @@ -155,15 +120,9 @@ export default function ChatView({ useEffect(() => { setChat((prevChat) => { const updatedChat = { ...prevChat, messages }; - // Save to sessionStorage - try { - window.sessionStorage.setItem(`goose-chat-${sessionId}`, JSON.stringify(updatedChat)); - } catch (e) { - console.error('Failed to save chat to sessionStorage:', e); - } return updatedChat; }); - }, [messages, sessionId]); + }, [messages, sessionId, resumedSession]); useEffect(() => { if (messages.length > 0) { diff --git a/ui/desktop/src/components/MoreMenu.tsx b/ui/desktop/src/components/MoreMenu.tsx index a38cb326..dbae2397 100644 --- a/ui/desktop/src/components/MoreMenu.tsx +++ b/ui/desktop/src/components/MoreMenu.tsx @@ -247,7 +247,10 @@ export default function MoreMenu({