@setoelkahfi / sigit / commits / cdc720e

Support session working directory and resource loading

Capture the project working directory from the editor and set it as the process cwd for each session. Update prompt handling to read file contents for ResourceLinks using the session cwd.

paydii committed Apr 25, 2026 at 00:40 UTC cdc720e14099e1d66b4bb26bfeb67adb1c33f5dc
2 files changed +246 -14
Cargo.toml
+1 -1
index cc33162..da7a2a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ path = "src/main.rs" [dependencies] # ACP protocol SDK -agent-client-protocol = { version = "0.10.4", features = ["unstable_session_fork"] } +agent-client-protocol = { version = "0.10.4", features = ["unstable_session_fork", "unstable_session_additional_directories"] } # Onde Inference engine (local LLM) onde = { path = "../onde" }
src/main.rs
+245 -13
index f1fbff5..55f485f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -62,6 +62,7 @@ use agent_client_protocol::{ }; use futures::future::LocalBoxFuture; use onde::inference::{ChatEngine, GgufModelConfig, ToolDefinition, ToolResult}; +use std::path::PathBuf; use tokio::sync::mpsc; use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; use tracing_subscriber::{EnvFilter, fmt as tracing_fmt}; @@ -200,6 +201,10 @@ fn agent_tools_as_onde() -> Vec<ToolDefinition> { struct SiGitAgent { engine: Arc<ChatEngine>, notification_tx: mpsc::Sender<SessionNotification>, + /// The project working directory provided by the editor via ACP session + /// creation. Tool calls use this as `cwd` so file operations target the + /// correct project, not wherever the agent process was spawned. + session_cwd: std::sync::Mutex<Option<PathBuf>>, } impl SiGitAgent { @@ -207,8 +212,18 @@ impl SiGitAgent { Self { engine, notification_tx, + session_cwd: std::sync::Mutex::new(None), } } + + /// Return the session working directory, falling back to the process cwd. + fn cwd(&self) -> PathBuf { + self.session_cwd + .lock() + .ok() + .and_then(|guard| guard.clone()) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))) + } } #[async_trait::async_trait(?Send)] @@ -248,12 +263,44 @@ impl Agent for SiGitAgent { &self, args: LoadSessionRequest, ) -> agent_client_protocol::Result<LoadSessionResponse> { - log::info!("load_session: id={}", args.session_id); + log::info!( + "load_session: id={}, cwd={}, additional_directories={:?}", + args.session_id, + args.cwd.display(), + args.additional_directories + .iter() + .map(|p| p.display().to_string()) + .collect::<Vec<_>>() + ); + + // Capture the project working directory from the editor. + if let Ok(mut guard) = self.session_cwd.lock() { + *guard = Some(args.cwd.clone()); + } + + // Set the process cwd so tool calls using relative paths land in the + // correct project directory. + if args.cwd.is_dir() { + if let Err(err) = std::env::set_current_dir(&args.cwd) { + log::warn!("could not set cwd to {}: {err}", args.cwd.display()); + } + } // Clear conversation history — siGit doesn't persist sessions, so a // "load" is effectively a fresh start with the same session ID. self.engine.clear_history().await; + // Tell the model which project directory it's working in. + self.engine + .push_history(onde::inference::ChatMessage::system(format!( + "The user's project working directory is {}. \ + Always use absolute paths under this directory for all file \ + and directory operations. This is the root of the project \ + the user has open in their editor.", + args.cwd.display() + ))) + .await; + Ok(LoadSessionResponse::new()) } @@ -262,41 +309,226 @@ impl Agent for SiGitAgent { args: ForkSessionRequest, ) -> agent_client_protocol::Result<ForkSessionResponse> { let new_id = SessionId::new(uuid::Uuid::new_v4().to_string()); - log::info!("fork_session: from={} new={new_id}", args.session_id); + log::info!( + "fork_session: from={} new={new_id}, cwd={}, additional_directories={:?}", + args.session_id, + args.cwd.display(), + args.additional_directories + .iter() + .map(|p| p.display().to_string()) + .collect::<Vec<_>>() + ); + + // Update cwd if the fork provides a different one. + if let Ok(mut guard) = self.session_cwd.lock() { + *guard = Some(args.cwd.clone()); + } + if args.cwd.is_dir() { + if let Err(err) = std::env::set_current_dir(&args.cwd) { + log::warn!("could not set cwd to {}: {err}", args.cwd.display()); + } + } // siGit doesn't persist history, so a fork is effectively a fresh // session — clear the conversation and let the user start over from // their edited message. self.engine.clear_history().await; + self.engine + .push_history(onde::inference::ChatMessage::system(format!( + "The user's project working directory is {}. \ + Always use absolute paths under this directory for all file \ + and directory operations. This is the root of the project \ + the user has open in their editor.", + args.cwd.display() + ))) + .await; + Ok(ForkSessionResponse::new(new_id)) } async fn new_session( &self, - _args: NewSessionRequest, + args: NewSessionRequest, ) -> agent_client_protocol::Result<NewSessionResponse> { let session_id = SessionId::new(uuid::Uuid::new_v4().to_string()); - log::info!("new_session: id={session_id}"); + log::info!( + "new_session: id={session_id}, cwd={}, additional_directories={:?}", + args.cwd.display(), + args.additional_directories + .iter() + .map(|p| p.display().to_string()) + .collect::<Vec<_>>() + ); + + // Capture the project working directory from the editor. + if let Ok(mut guard) = self.session_cwd.lock() { + *guard = Some(args.cwd.clone()); + } + if args.cwd.is_dir() { + if let Err(err) = std::env::set_current_dir(&args.cwd) { + log::warn!("could not set cwd to {}: {err}", args.cwd.display()); + } + } // Clear history — the model is already loaded. self.engine.clear_history().await; + self.engine + .push_history(onde::inference::ChatMessage::system(format!( + "The user's project working directory is {}. \ + Always use absolute paths under this directory for all file \ + and directory operations. This is the root of the project \ + the user has open in their editor.", + args.cwd.display() + ))) + .await; + Ok(NewSessionResponse::new(session_id)) } async fn prompt(&self, args: PromptRequest) -> agent_client_protocol::Result<PromptResponse> { let session_id = args.session_id.clone(); - let user_text: String = args - .prompt - .iter() - .filter_map(|block| match block { - ContentBlock::Text(t) => Some(t.text.as_str()), - _ => None, - }) - .collect::<Vec<_>>() - .join("\n"); + // Debug: log every content block the editor sends so we can see + // exactly what arrives for @ references, file context, etc. + for (i, block) in args.prompt.iter().enumerate() { + match block { + ContentBlock::Text(t) => { + log::info!( + "prompt({}) block[{}]: Text({} chars) = \"{}\"", + session_id, + i, + t.text.len(), + t.text.chars().take(200).collect::<String>() + ); + } + ContentBlock::Resource(embedded) => { + log::info!( + "prompt({}) block[{}]: EmbeddedResource = {:?}", + session_id, + i, + match &embedded.resource { + agent_client_protocol::EmbeddedResourceResource::TextResourceContents(t) => + format!("TextResource(uri={}, {} chars)", t.uri, t.text.len()), + agent_client_protocol::EmbeddedResourceResource::BlobResourceContents(b) => + format!("BlobResource(uri={})", b.uri), + _ => "Unknown".to_string(), + } + ); + } + ContentBlock::ResourceLink(link) => { + log::info!( + "prompt({}) block[{}]: ResourceLink(name={}, uri={}, title={:?}, desc={:?})", + session_id, + i, + link.name, + link.uri, + link.title, + link.description + ); + } + other => { + log::info!( + "prompt({}) block[{}]: Other({:?})", + session_id, + i, + std::mem::discriminant(other) + ); + } + } + } + + let mut parts: Vec<String> = Vec::new(); + + for block in &args.prompt { + match block { + ContentBlock::Text(t) => { + parts.push(t.text.clone()); + } + ContentBlock::Resource(embedded) => { + // Embedded file content sent by the editor (preferred over ResourceLink). + match &embedded.resource { + agent_client_protocol::EmbeddedResourceResource::TextResourceContents( + text_resource, + ) => { + parts.push(format!( + "\n--- {} ---\n{}\n--- end {} ---", + text_resource.uri, text_resource.text, text_resource.uri + )); + } + agent_client_protocol::EmbeddedResourceResource::BlobResourceContents( + blob, + ) => { + parts.push(format!("[binary resource: {}]", blob.uri)); + } + _ => { + log::debug!("ignoring unsupported embedded resource variant"); + } + } + } + ContentBlock::ResourceLink(link) => { + // The editor sent a reference but not the content — read it if it's a file. + let label = link.name.clone(); + + if let Some(raw_path) = link.uri.strip_prefix("file://") { + // Split off the #L<start>:<end> fragment if present. + let (file_path, line_range) = if let Some(hash_pos) = raw_path.rfind('#') { + let fragment = &raw_path[hash_pos + 1..]; + let path = &raw_path[..hash_pos]; + // Parse "L207:219" or "L207-219" → (207, 219) + let range = fragment.strip_prefix('L').and_then(|rest| { + let sep = if rest.contains(':') { ':' } else { '-' }; + let mut parts = rest.splitn(2, sep); + let start = parts.next()?.parse::<usize>().ok()?; + let end = parts.next()?.parse::<usize>().ok()?; + Some((start, end)) + }); + (path, range) + } else { + (raw_path, None) + }; + + match std::fs::read_to_string(file_path) { + Ok(contents) => { + let extracted = if let Some((start, end)) = line_range { + // Extract only the requested line range (1-based, inclusive). + let selected: Vec<&str> = contents + .lines() + .enumerate() + .filter(|(i, _)| { + let line_num = i + 1; + line_num >= start && line_num <= end + }) + .map(|(_, line)| line) + .collect(); + format!( + "\n--- {label} ({file_path} lines {start}-{end}) ---\n{}\n--- end {label} ---", + selected.join("\n") + ) + } else { + format!( + "\n--- {label} ({file_path}) ---\n{contents}\n--- end {label} ---" + ) + }; + parts.push(extracted); + } + Err(err) => { + log::warn!("could not read ResourceLink {}: {err}", link.uri); + parts.push(format!("[referenced file: {label} ({file_path})]")); + } + } + } else { + parts.push(format!("[resource link: {label} ({})]", link.uri)); + } + } + _ => { + log::debug!("ignoring unsupported content block type in prompt"); + } + } + } + + let user_text = parts.join("\n"); if user_text.trim().is_empty() { return Ok(PromptResponse::new(StopReason::EndTurn));