diff --git a/crates/goose-acp/Cargo.toml b/crates/goose-acp/Cargo.toml index d9b4b4b7..80e1c1ed 100644 --- a/crates/goose-acp/Cargo.toml +++ b/crates/goose-acp/Cargo.toml @@ -28,6 +28,7 @@ goose-mcp = { path = "../goose-mcp" } rmcp = { workspace = true } sacp = { workspace = true } agent-client-protocol-schema = { version = "0.10", features = ["unstable"] } +async-trait = { workspace = true } anyhow = { workspace = true } tokio = { workspace = true } tokio-util = { workspace = true, features = ["compat", "rt"] } diff --git a/crates/goose-acp/src/fs.rs b/crates/goose-acp/src/fs.rs new file mode 100644 index 00000000..aff7173b --- /dev/null +++ b/crates/goose-acp/src/fs.rs @@ -0,0 +1,248 @@ +use async_trait::async_trait; +use fs_err as fs; +use goose::agents::mcp_client::{Error as McpError, McpClientTrait}; +use goose::agents::platform_extensions::developer::edit::{ + resolve_path, string_replace, FileEditParams, FileReadParams, FileWriteParams, +}; +use goose::agents::platform_extensions::developer::DeveloperClient; +use rmcp::model::{CallToolResult, Content as RmcpContent, Tool, ToolAnnotations}; +use sacp::schema::{ReadTextFileRequest, SessionId, WriteTextFileRequest}; +use sacp::{AgentToClient, JrConnectionCx}; +use schemars::schema_for; +use std::path::Path; +use std::sync::Arc; +use tokio_util::sync::CancellationToken; + +async fn acp_read_text_file( + cx: &JrConnectionCx, + session_id: &SessionId, + path: &Path, + line: Option, + limit: Option, +) -> Result { + let mut request = ReadTextFileRequest::new(session_id.clone(), path.to_path_buf()); + if let Some(l) = line { + request = request.line(l); + } + if let Some(l) = limit { + request = request.limit(l); + } + let response = cx + .send_request(request) + .block_task() + .await + .map_err(|e| format!("{e:?}"))?; + Ok(response.content) +} + +async fn acp_write_text_file( + cx: &JrConnectionCx, + session_id: &SessionId, + path: &Path, + content: &str, +) -> Result<(), String> { + let request = + WriteTextFileRequest::new(session_id.clone(), path.to_path_buf(), content.to_string()); + cx.send_request(request) + .block_task() + .await + .map_err(|e| format!("{e:?}"))?; + Ok(()) +} + +pub(crate) struct AcpTools { + pub(crate) inner: Arc, + pub(crate) cx: JrConnectionCx, + pub(crate) session_id: SessionId, + pub(crate) fs_read: bool, + pub(crate) fs_write: bool, +} + +fn error_result(msg: impl std::fmt::Display) -> CallToolResult { + CallToolResult::error(vec![RmcpContent::text(msg.to_string()).with_priority(0.0)]) +} + +fn fail(action: &str, path: &str, err: impl std::fmt::Display) -> CallToolResult { + error_result(format!("Failed to {action} {path}: {err}")) +} + +fn read_tool() -> Tool { + let schema = serde_json::to_value(schema_for!(FileReadParams)) + .expect("schema serialization should succeed") + .as_object() + .expect("schema should serialize to an object") + .clone(); + Tool::new("read", "Read a text file from disk.", schema).annotate( + ToolAnnotations::with_title("Read") + .read_only(true) + .destructive(false) + .idempotent(false) + .open_world(false), + ) +} + +pub(crate) fn with_location_meta( + mut result: CallToolResult, + path: &Path, + line: Option, +) -> CallToolResult { + let location = serde_json::json!({ + "tool_locations": [{"path": path.to_string_lossy(), "line": line}] + }); + result.meta = Some(serde_json::from_value(location).unwrap()); + result +} + +impl AcpTools { + fn parse_args( + arguments: Option, + ) -> Result { + DeveloperClient::parse_args(arguments).map_err(|e| format!("Error: {e}")) + } + + async fn read_content(&self, path: &Path) -> Result { + if self.fs_read { + acp_read_text_file(&self.cx, &self.session_id, path, None, None).await + } else { + fs::read_to_string(path).map_err(|e| e.to_string()) + } + } + + async fn acp_read( + &self, + arguments: Option, + working_dir: Option<&str>, + ) -> Result { + let params: FileReadParams = match Self::parse_args(arguments) { + Ok(p) => p, + Err(e) => return Ok(error_result(e)), + }; + let path = resolve_path(¶ms.path, working_dir.map(Path::new)); + match acp_read_text_file(&self.cx, &self.session_id, &path, params.line, params.limit).await + { + Ok(content) => Ok(with_location_meta( + CallToolResult::success(vec![RmcpContent::text(content).with_priority(0.0)]), + &path, + params.line, + )), + Err(e) => Ok(fail("read", ¶ms.path, e)), + } + } + + async fn acp_write( + &self, + arguments: Option, + working_dir: Option<&str>, + ) -> Result { + let params: FileWriteParams = match Self::parse_args(arguments) { + Ok(p) => p, + Err(e) => return Ok(error_result(e)), + }; + let path = resolve_path(¶ms.path, working_dir.map(Path::new)); + match acp_write_text_file(&self.cx, &self.session_id, &path, ¶ms.content).await { + Ok(()) => { + let line_count = params.content.lines().count(); + let action = if path.exists() { "Wrote" } else { "Created" }; + Ok(with_location_meta( + CallToolResult::success(vec![RmcpContent::text(format!( + "{action} {} ({line_count} lines)", + params.path + )) + .with_priority(0.0)]), + &path, + Some(1), + )) + } + Err(e) => Ok(fail("write", ¶ms.path, e)), + } + } + + async fn acp_edit( + &self, + arguments: Option, + working_dir: Option<&str>, + ) -> Result { + let params: FileEditParams = match Self::parse_args(arguments) { + Ok(p) => p, + Err(e) => return Ok(error_result(e)), + }; + let path = resolve_path(¶ms.path, working_dir.map(Path::new)); + + let content = match self.read_content(&path).await { + Ok(c) => c, + Err(e) => return Ok(fail("read", ¶ms.path, e)), + }; + + let new_content = match string_replace(&content, ¶ms.before, ¶ms.after) { + Ok(c) => c, + Err(msg) => return Ok(error_result(msg)), + }; + + let write_result = if self.fs_write { + acp_write_text_file(&self.cx, &self.session_id, &path, &new_content).await + } else { + fs::write(&path, &new_content).map_err(|e| e.to_string()) + }; + + match write_result { + Ok(()) => { + let old_lines = params.before.lines().count(); + let new_lines = params.after.lines().count(); + Ok(with_location_meta( + CallToolResult::success(vec![RmcpContent::text(format!( + "Edited {} ({old_lines} lines -> {new_lines} lines)", + params.path + )) + .with_priority(0.0)]), + &path, + Some(1), + )) + } + Err(e) => Ok(fail("write", ¶ms.path, e)), + } + } +} + +#[async_trait] +impl McpClientTrait for AcpTools { + async fn list_tools( + &self, + session_id: &str, + next_cursor: Option, + cancellation_token: CancellationToken, + ) -> Result { + let mut result = self + .inner + .list_tools(session_id, next_cursor, cancellation_token) + .await?; + if self.fs_read { + result.tools.insert(0, read_tool()); + } + Ok(result) + } + + async fn call_tool( + &self, + session_id: &str, + name: &str, + arguments: Option, + working_dir: Option<&str>, + cancellation_token: CancellationToken, + ) -> Result { + match name { + "read" if self.fs_read => self.acp_read(arguments, working_dir).await, + "write" if self.fs_write => self.acp_write(arguments, working_dir).await, + // edit reads then writes: require both caps so we don't mix editor buffer with local disk + "edit" if self.fs_read && self.fs_write => self.acp_edit(arguments, working_dir).await, + _ => { + self.inner + .call_tool(session_id, name, arguments, working_dir, cancellation_token) + .await + } + } + } + + fn get_info(&self) -> Option<&rmcp::model::InitializeResult> { + self.inner.get_info() + } +} diff --git a/crates/goose-acp/src/lib.rs b/crates/goose-acp/src/lib.rs index 4772a8e4..84c17d24 100644 --- a/crates/goose-acp/src/lib.rs +++ b/crates/goose-acp/src/lib.rs @@ -2,6 +2,7 @@ mod adapters; pub mod custom_requests; +mod fs; pub mod server; pub mod server_factory; pub mod transport; diff --git a/crates/goose-acp/src/server.rs b/crates/goose-acp/src/server.rs index 94efd2a2..495e9bfc 100644 --- a/crates/goose-acp/src/server.rs +++ b/crates/goose-acp/src/server.rs @@ -1,8 +1,11 @@ use crate::custom_requests::*; +use crate::fs::AcpTools; use anyhow::Result; use fs_err as fs; use goose::acp::PermissionDecision; use goose::agents::extension::{Envs, PLATFORM_EXTENSIONS}; +use goose::agents::mcp_client::McpClientTrait; +use goose::agents::platform_extensions::developer::DeveloperClient; use goose::agents::{Agent, AgentConfig, ExtensionConfig, GoosePlatform, SessionConfig}; use goose::builtin_extension::register_builtin_extensions; use goose::config::base::CONFIG_YAML_NAME; @@ -24,10 +27,10 @@ use rmcp::model::{CallToolResult, RawContent, ResourceContents, Role}; use sacp::schema::{ AgentCapabilities, AuthMethod, AuthenticateRequest, AuthenticateResponse, BlobResourceContents, CancelNotification, Content, ContentBlock, ContentChunk, EmbeddedResource, - EmbeddedResourceResource, ImageContent, InitializeRequest, InitializeResponse, - ListSessionsResponse, LoadSessionRequest, LoadSessionResponse, McpCapabilities, McpServer, - ModelId, ModelInfo, NewSessionRequest, NewSessionResponse, PermissionOption, - PermissionOptionKind, PromptCapabilities, PromptRequest, PromptResponse, + EmbeddedResourceResource, FileSystemCapability, ImageContent, InitializeRequest, + InitializeResponse, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse, + McpCapabilities, McpServer, ModelId, ModelInfo, NewSessionRequest, NewSessionResponse, + PermissionOption, PermissionOptionKind, PromptCapabilities, PromptRequest, PromptResponse, RequestPermissionOutcome, RequestPermissionRequest, ResourceLink, SessionCapabilities, SessionId, SessionInfo, SessionListCapabilities, SessionModelState, SessionNotification, SessionUpdate, SetSessionModelRequest, SetSessionModelResponse, StopReason, TextContent, @@ -55,12 +58,13 @@ struct GooseAcpSession { pub struct GooseAcpAgent { sessions: Arc>>, provider_factory: ProviderConstructor, + builtins: Vec, + client_fs_capabilities: Mutex, config_dir: std::path::PathBuf, session_manager: Arc, permission_manager: Arc, goose_mode: goose::config::GooseMode, disable_session_naming: bool, - builtins: Vec, } fn mcp_server_to_extension_config(mcp_server: McpServer) -> Result { @@ -96,6 +100,13 @@ fn mcp_server_to_extension_config(mcp_server: McpServer) -> Result) -> Option { + arguments + .and_then(|args| args.get("line")) + .and_then(|v| v.as_u64()) + .map(|l| l as u32) +} + fn create_tool_location(path: &str, line: Option) -> ToolCallLocation { let mut loc = ToolCallLocation::new(path); if let Some(l) = line { @@ -105,7 +116,29 @@ fn create_tool_location(path: &str, line: Option) -> ToolCallLocation { } fn is_developer_file_tool(tool_name: &str) -> bool { - matches!(tool_name, "write" | "edit") + matches!(tool_name, "read" | "write" | "edit") +} + +fn extract_locations_from_meta( + tool_response: &goose::conversation::message::ToolResponse, +) -> Option> { + let result = tool_response.tool_result.as_ref().ok()?; + let meta = result.meta.as_ref()?; + let locations_val = meta.get("tool_locations")?; + let entries: Vec = serde_json::from_value(locations_val.clone()).ok()?; + let locations = entries + .into_iter() + .filter_map(|entry| { + let path = entry.get("path")?.as_str()?; + let line = entry.get("line").and_then(|v| v.as_u64()).map(|l| l as u32); + Some(create_tool_location(path, line)) + }) + .collect::>(); + if locations.is_empty() { + None + } else { + Some(locations) + } } fn extract_tool_locations( @@ -127,6 +160,12 @@ fn extract_tool_locations( .and_then(|p| p.as_str()); if let Some(path_str) = path_str { + if matches!(tool_name, "read") { + let line = get_requested_line(tool_call.arguments.as_ref()); + locations.push(create_tool_location(path_str, line)); + return locations; + } + if matches!(tool_name, "write" | "edit") { locations.push(create_tool_location(path_str, Some(1))); return locations; @@ -243,47 +282,23 @@ fn format_tool_name(tool_name: &str) -> String { } } -async fn add_builtins(agent: &Agent, builtins: Vec) { - for builtin in builtins { - let config = if PLATFORM_EXTENSIONS.contains_key(builtin.as_str()) { - ExtensionConfig::Platform { - name: builtin.clone(), - description: builtin.clone(), - display_name: None, - bundled: None, - available_tools: Vec::new(), - } - } else { - ExtensionConfig::Builtin { - name: builtin.clone(), - display_name: None, - timeout: None, - bundled: None, - description: builtin.clone(), - available_tools: Vec::new(), - } - }; - - match agent - .extension_manager - .add_extension(config, None, None, None) - .await - { - Ok(_) => info!(extension = %builtin, "extension loaded"), - Err(e) => warn!(extension = %builtin, error = %e, "extension load failed"), +fn builtin_to_extension_config(name: &str) -> ExtensionConfig { + if let Some(def) = PLATFORM_EXTENSIONS.get(name) { + ExtensionConfig::Platform { + name: def.name.into(), + description: def.description.into(), + display_name: Some(def.display_name.into()), + bundled: Some(true), + available_tools: vec![], } - } -} -async fn add_extensions(agent: &Agent, extensions: Vec) { - for extension in extensions { - let name = extension.name().to_string(); - match agent - .extension_manager - .add_extension(extension, None, None, None) - .await - { - Ok(_) => info!(extension = %name, "extension loaded"), - Err(e) => warn!(extension = %name, error = %e, "extension load failed"), + } else { + ExtensionConfig::Builtin { + name: name.into(), + display_name: None, + timeout: None, + bundled: Some(true), + description: name.into(), + available_tools: vec![], } } } @@ -324,16 +339,21 @@ impl GooseAcpAgent { Ok(Self { sessions: Arc::new(Mutex::new(HashMap::new())), provider_factory, + builtins, + client_fs_capabilities: Mutex::new(FileSystemCapability::new()), config_dir, session_manager, permission_manager, goose_mode, disable_session_naming, - builtins, }) } - async fn create_agent_for_session(&self) -> Arc { + async fn create_agent_for_session( + &self, + cx: Option<&JrConnectionCx>, + session_id: Option<&SessionId>, + ) -> Result> { let agent = Agent::with_config(AgentConfig::new( Arc::clone(&self.session_manager), Arc::clone(&self.permission_manager), @@ -345,13 +365,73 @@ impl GooseAcpAgent { let agent = Arc::new(agent); let config_path = self.config_dir.join(CONFIG_YAML_NAME); - if let Ok(config_file) = Config::new(&config_path, "goose") { - let extensions = get_enabled_extensions_with_config(&config_file); - add_extensions(&agent, extensions).await; - } - add_builtins(&agent, self.builtins.clone()).await; + let mut extensions = Config::new(&config_path, "goose") + .ok() + .map(|c| get_enabled_extensions_with_config(&c)) + .unwrap_or_default(); + extensions.extend(self.builtins.iter().map(|b| builtin_to_extension_config(b))); - agent + let caps = self.client_fs_capabilities.lock().await.clone(); + let acp_developer = match (cx, session_id) { + (Some(cx), Some(sid)) + if (caps.read_text_file || caps.write_text_file) + && extensions.iter().any(|e| e.name() == "developer") => + { + let context = agent.extension_manager.get_context().clone(); + let client: Arc = Arc::new(AcpTools { + inner: Arc::new(DeveloperClient::new(context)?), + cx: cx.clone(), + session_id: sid.clone(), + fs_read: caps.read_text_file, + fs_write: caps.write_text_file, + }); + let dev_ext = extensions.iter().find(|e| e.name() == "developer"); + let available_tools = dev_ext + .and_then(|e| match e { + ExtensionConfig::Platform { + available_tools, .. + } => Some(available_tools.clone()), + _ => None, + }) + .unwrap_or_default(); + let def = &PLATFORM_EXTENSIONS["developer"]; + let config = ExtensionConfig::Platform { + name: def.name.into(), + description: def.description.into(), + display_name: Some(def.display_name.into()), + bundled: Some(true), + available_tools, + }; + Some((client, config)) + } + _ => None, + }; + let skip_developer = acp_developer.is_some(); + + for ext in extensions { + if skip_developer && ext.name() == "developer" { + continue; + } + let name = ext.name().to_string(); + match agent + .extension_manager + .add_extension(ext, None, None, None) + .await + { + Ok(_) => info!(extension = %name, "extension loaded"), + Err(e) => warn!(extension = %name, error = %e, "extension load failed"), + } + } + + if let Some((client, config)) = acp_developer { + let info = client.get_info().cloned(); + agent + .extension_manager + .add_client("developer".into(), config, client, info, None) + .await; + } + + Ok(agent) } pub async fn has_session(&self, session_id: &str) -> bool { @@ -491,11 +571,13 @@ impl GooseAcpAgent { let content = build_tool_call_content(&tool_response.tool_result); - let locations = if let Some(tool_request) = session.tool_requests.get(&tool_response.id) { - extract_tool_locations(tool_request, tool_response) - } else { - Vec::new() - }; + let locations = extract_locations_from_meta(tool_response).unwrap_or_else(|| { + if let Some(tool_request) = session.tool_requests.get(&tool_response.id) { + extract_tool_locations(tool_request, tool_response) + } else { + Vec::new() + } + }); let mut fields = ToolCallUpdateFields::new().status(status).content(content); if !locations.is_empty() { @@ -649,6 +731,8 @@ impl GooseAcpAgent { ) -> Result { debug!(?args, "initialize request"); + *self.client_fs_capabilities.lock().await = args.client_capabilities.fs.clone(); + let capabilities = AgentCapabilities::new() .load_session(true) .session_capabilities(SessionCapabilities::new().list(SessionListCapabilities::new())) @@ -672,6 +756,7 @@ impl GooseAcpAgent { async fn on_new_session( &self, + cx: &JrConnectionCx, args: NewSessionRequest, ) -> Result { debug!(?args, "new session request"); @@ -688,7 +773,15 @@ impl GooseAcpAgent { sacp::Error::internal_error().data(format!("Failed to create session: {}", e)) })?; - let agent = self.create_agent_for_session().await; + let session_id = SessionId::new(goose_session.id.clone()); + + let agent = self + .create_agent_for_session(Some(cx), Some(&session_id)) + .await + .map_err(|e| { + sacp::Error::internal_error().data(format!("Failed to create agent: {}", e)) + })?; + let provider = self .init_provider(&agent, &goose_session) .await @@ -750,8 +843,8 @@ impl GooseAcpAgent { async fn on_load_session( &self, - args: LoadSessionRequest, cx: &JrConnectionCx, + args: LoadSessionRequest, ) -> Result { debug!(?args, "load session request"); @@ -766,7 +859,15 @@ impl GooseAcpAgent { .data(format!("Failed to load session {}: {}", session_id, e)) })?; - let agent = self.create_agent_for_session().await; + let acp_session_id = SessionId::new(session_id.clone()); + + let agent = self + .create_agent_for_session(Some(cx), Some(&acp_session_id)) + .await + .map_err(|e| { + sacp::Error::internal_error().data(format!("Failed to create agent: {}", e)) + })?; + let provider = self .init_provider(&agent, &goose_session) .await @@ -859,8 +960,8 @@ impl GooseAcpAgent { async fn on_prompt( &self, - args: PromptRequest, cx: &JrConnectionCx, + args: PromptRequest, ) -> Result { let session_id = args.session_id.0.to_string(); let cancel_token = CancellationToken::new(); @@ -1221,13 +1322,13 @@ impl JrMessageHandler for GooseAcpHandler { .await .if_request( |req: NewSessionRequest, req_cx: JrRequestCx| async { - req_cx.respond(agent.on_new_session(req).await?) + req_cx.respond(agent.on_new_session(&cx, req).await?) }, ) .await .if_request( |req: LoadSessionRequest, req_cx: JrRequestCx| async { - req_cx.respond(agent.on_load_session(req, &cx).await?) + req_cx.respond(agent.on_load_session(&cx, req).await?) }, ) .await @@ -1236,7 +1337,7 @@ impl JrMessageHandler for GooseAcpHandler { let agent = agent.clone(); let cx_clone = cx.clone(); cx.spawn(async move { - match agent.on_prompt(req, &cx_clone).await { + match agent.on_prompt(&cx_clone, req).await { Ok(response) => { req_cx.respond(response)?; } @@ -1342,11 +1443,15 @@ pub async fn run(builtins: Vec) -> Result<()> { #[cfg(test)] mod tests { use super::*; + use goose::conversation::message::{ToolRequest, ToolResponse}; + use goose::providers::errors::ProviderError; + use rmcp::model::{CallToolRequestParams, Content as RmcpContent}; use sacp::schema::{ EnvVariable, HttpHeader, McpServer, McpServerHttp, McpServerSse, McpServerStdio, PermissionOptionId, ResourceLink, SelectedPermissionOutcome, }; use std::io::Write; + use std::path::PathBuf; use tempfile::NamedTempFile; use test_case::test_case; @@ -1492,8 +1597,6 @@ print(\"hello, world\") assert_eq!(outcome_to_confirmation(&input), expected); } - use goose::providers::errors::ProviderError; - struct MockModelProvider { models: Result, ProviderError>, } @@ -1560,4 +1663,142 @@ print(\"hello, world\") let provider = MockModelProvider { models }; build_model_state(&provider, current_model).await } + + fn json_object(pairs: Vec<(&str, serde_json::Value)>) -> rmcp::model::JsonObject { + pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect() + } + + #[test_case(None => None ; "none arguments")] + #[test_case(Some(json_object(vec![])) => None ; "missing line key")] + #[test_case(Some(json_object(vec![("line", serde_json::json!(5))])) => Some(5) ; "line present")] + #[test_case(Some(json_object(vec![("line", serde_json::json!("not_a_number"))])) => None ; "line not a number")] + fn test_get_requested_line(arguments: Option) -> Option { + get_requested_line(arguments.as_ref()) + } + + #[test_case("read", true ; "read is developer file tool")] + #[test_case("write", true ; "write is developer file tool")] + #[test_case("edit", true ; "edit is developer file tool")] + #[test_case("shell", false ; "shell is not developer file tool")] + #[test_case("analyze", false ; "analyze is not developer file tool")] + fn test_is_developer_file_tool(tool_name: &str, expected: bool) { + assert_eq!(is_developer_file_tool(tool_name), expected); + } + + #[test_case( + ToolRequest { + id: "req_1".to_string(), + tool_call: Ok(CallToolRequestParams::new("read").with_arguments(serde_json::json!({"path": "/tmp/f.txt", "line": 5}).as_object().unwrap().clone())), + metadata: None, tool_meta: None, + }, + ToolResponse { + id: "req_1".to_string(), + tool_result: Ok(CallToolResult::success(vec![RmcpContent::text("")])), + metadata: None, + } + => vec![(PathBuf::from("/tmp/f.txt"), Some(5))] + ; "read returns requested line" + )] + #[test_case( + ToolRequest { + id: "req_1".to_string(), + tool_call: Ok(CallToolRequestParams::new("read").with_arguments(serde_json::json!({"path": "/tmp/f.txt"}).as_object().unwrap().clone())), + metadata: None, tool_meta: None, + }, + ToolResponse { + id: "req_1".to_string(), + tool_result: Ok(CallToolResult::success(vec![RmcpContent::text("")])), + metadata: None, + } + => vec![(PathBuf::from("/tmp/f.txt"), None)] + ; "read without line" + )] + #[test_case( + ToolRequest { + id: "req_1".to_string(), + tool_call: Ok(CallToolRequestParams::new("write").with_arguments(serde_json::json!({"path": "/tmp/f.txt", "content": "hi"}).as_object().unwrap().clone())), + metadata: None, tool_meta: None, + }, + ToolResponse { + id: "req_1".to_string(), + tool_result: Ok(CallToolResult::success(vec![RmcpContent::text("")])), + metadata: None, + } + => vec![(PathBuf::from("/tmp/f.txt"), Some(1))] + ; "write returns line 1" + )] + #[test_case( + ToolRequest { + id: "req_1".to_string(), + tool_call: Ok(CallToolRequestParams::new("edit").with_arguments(serde_json::json!({"path": "/tmp/f.txt", "before": "a", "after": "b"}).as_object().unwrap().clone())), + metadata: None, tool_meta: None, + }, + ToolResponse { + id: "req_1".to_string(), + tool_result: Ok(CallToolResult::success(vec![RmcpContent::text("")])), + metadata: None, + } + => vec![(PathBuf::from("/tmp/f.txt"), Some(1))] + ; "edit returns line 1" + )] + #[test_case( + ToolRequest { + id: "req_1".to_string(), + tool_call: Ok(CallToolRequestParams::new("shell").with_arguments(serde_json::json!({"command": "ls"}).as_object().unwrap().clone())), + metadata: None, tool_meta: None, + }, + ToolResponse { + id: "req_1".to_string(), + tool_result: Ok(CallToolResult::success(vec![RmcpContent::text("")])), + metadata: None, + } + => Vec::<(PathBuf, Option)>::new() + ; "non file tool returns empty" + )] + fn test_extract_tool_locations( + request: ToolRequest, + response: ToolResponse, + ) -> Vec<(PathBuf, Option)> { + extract_tool_locations(&request, &response) + .into_iter() + .map(|loc| (loc.path, loc.line)) + .collect() + } + + fn response_with_meta(meta: Option) -> ToolResponse { + let mut result = CallToolResult::success(vec![RmcpContent::text("")]); + result.meta = meta.map(|v| serde_json::from_value(v).unwrap()); + ToolResponse { + id: "req_1".to_string(), + tool_result: Ok(result), + metadata: None, + } + } + + #[test_case( + response_with_meta(Some(serde_json::json!({"tool_locations": [{"path": "/tmp/f.txt", "line": 5}]}))) + => Some(vec![(PathBuf::from("/tmp/f.txt"), Some(5))]) + ; "meta with path and line" + )] + #[test_case( + response_with_meta(Some(serde_json::json!({"tool_locations": [{"path": "/tmp/f.txt"}]}))) + => Some(vec![(PathBuf::from("/tmp/f.txt"), None)]) + ; "meta with path no line" + )] + #[test_case( + response_with_meta(Some(serde_json::json!({}))) + => None + ; "meta without tool_locations key" + )] + #[test_case( + response_with_meta(None) + => None + ; "no meta" + )] + fn test_extract_locations_from_meta( + response: ToolResponse, + ) -> Option)>> { + extract_locations_from_meta(&response) + .map(|locs| locs.into_iter().map(|loc| (loc.path, loc.line)).collect()) + } } diff --git a/crates/goose-acp/tests/common_tests/mod.rs b/crates/goose-acp/tests/common_tests/mod.rs index 6eb4b6f0..2290dae4 100644 --- a/crates/goose-acp/tests/common_tests/mod.rs +++ b/crates/goose-acp/tests/common_tests/mod.rs @@ -4,11 +4,15 @@ #[path = "../fixtures/mod.rs"] pub mod fixtures; -use fixtures::{Connection, OpenAiFixture, PermissionDecision, Session, TestConnectionConfig}; +use fixtures::{ + initialize_agent, Connection, FsFixture, OpenAiFixture, PermissionDecision, Session, + TestConnectionConfig, +}; use fs_err as fs; use goose::config::base::CONFIG_YAML_NAME; use goose::config::GooseMode; use goose::providers::provider_registry::ProviderConstructor; +use goose_acp::server::GooseAcpAgent; use goose_test_support::{ExpectedSessionId, McpFixture, FAKE_CODE, TEST_IMAGE_B64, TEST_MODEL}; use sacp::schema::{McpServer, McpServerHttp, ModelId, ToolCallStatus}; use std::sync::Arc; @@ -54,6 +58,122 @@ pub async fn run_config_mcp() { expected_session_id.assert_matches(&session.session_id().0); } +// Also proves developer loaded from config.yaml (not CLI args) gets ACP fs delegation. +pub async fn run_fs_read_text_file_true() { + let temp_dir = tempfile::tempdir().unwrap(); + let config_yaml = format!( + "GOOSE_MODEL: {TEST_MODEL}\nGOOSE_PROVIDER: openai\nextensions:\n developer:\n enabled: true\n type: platform\n name: developer\n description: Developer\n display_name: Developer\n bundled: true\n available_tools: []\n" + ); + fs::write(temp_dir.path().join(CONFIG_YAML_NAME), config_yaml).unwrap(); + + let expected_session_id = ExpectedSessionId::default(); + let prompt = "Use the read tool to read /tmp/test_acp_read.txt and output only its contents."; + let openai = OpenAiFixture::new( + vec![ + ( + prompt.to_string(), + include_str!("../test_data/openai_fs_read_tool_call.txt"), + ), + ( + r#""content":"test-read-content-12345""#.into(), + include_str!("../test_data/openai_fs_read_tool_result.txt"), + ), + ], + expected_session_id.clone(), + ) + .await; + + let fs = FsFixture::new(); + let config = TestConnectionConfig { + read_text_file: Some(fs.read_handler("/tmp/test_acp_read.txt", "test-read-content-12345")), + data_root: temp_dir.path().to_path_buf(), + ..Default::default() + }; + let mut conn = C::new(config, openai).await; + let (mut session, _) = conn.new_session().await; + expected_session_id.set(session.session_id().0.to_string()); + + let output = session.prompt(prompt, PermissionDecision::Cancel).await; + assert_eq!(output.text, "test-read-content-12345"); + fs.assert_called(); + expected_session_id.assert_matches(&session.session_id().0); +} + +pub async fn run_fs_write_text_file_false() { + let _ = fs::remove_file("/tmp/test_acp_write.txt"); + + let expected_session_id = ExpectedSessionId::default(); + let prompt = + "Use the write tool to write 'test-write-content-67890' to /tmp/test_acp_write.txt"; + let openai = OpenAiFixture::new( + vec![ + ( + prompt.to_string(), + include_str!("../test_data/openai_fs_write_tool_call.txt"), + ), + ( + r#"Created /tmp/test_acp_write.txt"#.into(), + include_str!("../test_data/openai_fs_write_tool_result.txt"), + ), + ], + expected_session_id.clone(), + ) + .await; + + let config = TestConnectionConfig { + builtins: vec!["developer".to_string()], + ..Default::default() + }; + let mut conn = C::new(config, openai).await; + let (mut session, _) = conn.new_session().await; + expected_session_id.set(session.session_id().0.to_string()); + + let output = session.prompt(prompt, PermissionDecision::AllowOnce).await; + assert!(!output.text.is_empty()); + assert_eq!( + fs::read_to_string("/tmp/test_acp_write.txt").unwrap(), + "test-write-content-67890" + ); + expected_session_id.assert_matches(&session.session_id().0); +} + +pub async fn run_fs_write_text_file_true() { + let expected_session_id = ExpectedSessionId::default(); + let prompt = + "Use the write tool to write 'test-write-content-67890' to /tmp/test_acp_write.txt"; + let openai = OpenAiFixture::new( + vec![ + ( + prompt.to_string(), + include_str!("../test_data/openai_fs_write_tool_call.txt"), + ), + ( + r#"Created /tmp/test_acp_write.txt"#.into(), + include_str!("../test_data/openai_fs_write_tool_result.txt"), + ), + ], + expected_session_id.clone(), + ) + .await; + + let fs = FsFixture::new(); + let config = TestConnectionConfig { + builtins: vec!["developer".to_string()], + write_text_file: Some( + fs.write_handler("/tmp/test_acp_write.txt", "test-write-content-67890"), + ), + ..Default::default() + }; + let mut conn = C::new(config, openai).await; + let (mut session, _) = conn.new_session().await; + expected_session_id.set(session.session_id().0.to_string()); + + let output = session.prompt(prompt, PermissionDecision::AllowOnce).await; + assert!(!output.text.is_empty()); + fs.assert_called(); + expected_session_id.assert_matches(&session.session_id().0); +} + pub async fn run_initialize_doesnt_hit_provider() { let provider_factory: ProviderConstructor = Arc::new(|_, _| Box::pin(async { Err(anyhow::anyhow!("no provider configured")) })); @@ -72,6 +192,34 @@ pub async fn run_initialize_doesnt_hit_provider() { .any(|m| &*m.id.0 == "goose-provider")); } +#[allow(dead_code)] +pub async fn run_initialize_without_provider() { + let temp_dir = tempfile::tempdir().unwrap(); + + let provider_factory: ProviderConstructor = + Arc::new(|_, _| Box::pin(async { Err(anyhow::anyhow!("no provider configured")) })); + + let agent = Arc::new( + GooseAcpAgent::new( + provider_factory, + vec![], + temp_dir.path().to_path_buf(), + temp_dir.path().to_path_buf(), + GooseMode::Auto, + false, + ) + .await + .unwrap(), + ); + + let resp = initialize_agent(agent).await; + assert!(!resp.auth_methods.is_empty()); + assert!(resp + .auth_methods + .iter() + .any(|m| &*m.id.0 == "goose-provider")); +} + pub async fn run_load_model() { let expected_session_id = ExpectedSessionId::default(); let openai = OpenAiFixture::new( diff --git a/crates/goose-acp/tests/fixtures/mod.rs b/crates/goose-acp/tests/fixtures/mod.rs index b5b4056c..5c25eb61 100644 --- a/crates/goose-acp/tests/fixtures/mod.rs +++ b/crates/goose-acp/tests/fixtures/mod.rs @@ -13,10 +13,12 @@ use goose::providers::provider_registry::ProviderConstructor; use goose::session_context::SESSION_ID_HEADER; use goose_acp::server::{serve, GooseAcpAgent}; use goose_test_support::{ExpectedSessionId, TEST_MODEL}; -use sacp::schema::{AuthMethod, McpServer, SessionModelState, ToolCallStatus}; +use sacp::schema::{ + AuthMethod, McpServer, ReadTextFileRequest, ReadTextFileResponse, SessionModelState, + ToolCallStatus, WriteTextFileRequest, WriteTextFileResponse, +}; use std::collections::VecDeque; use std::future::Future; -use std::path::Path; use std::path::PathBuf; use std::sync::{Arc, Mutex}; use tokio::task::JoinHandle; @@ -147,7 +149,7 @@ pub async fn serve_agent_in_process( pub async fn spawn_acp_server_in_process( openai_base_url: &str, builtins: &[String], - data_root: &Path, + data_root: &std::path::Path, goose_mode: GooseMode, provider_factory: Option, ) -> (DuplexTransport, JoinHandle<()>, Arc) { @@ -198,12 +200,80 @@ pub struct TestOutput { pub tool_status: Option, } +type ReadTextFileHandler = + Arc Result + Send + Sync>; +type WriteTextFileHandler = + Arc Result + Send + Sync>; + +#[derive(Clone)] +pub struct FsFixture { + calls: Arc>>>, +} + +impl FsFixture { + pub fn new() -> Self { + Self { + calls: Arc::new(Mutex::new(Vec::new())), + } + } + + pub fn read_handler(&self, expected_path: &str, content: &str) -> ReadTextFileHandler { + let calls = self.calls.clone(); + let expected_path = expected_path.to_string(); + let content = content.to_string(); + Arc::new(move |req: &ReadTextFileRequest| { + let path = req.path.to_str().unwrap_or(""); + if path != expected_path { + let err = format!("expected path {expected_path}, got {path}"); + calls.lock().unwrap().push(Err(err.clone())); + return Err(err); + } + calls.lock().unwrap().push(Ok(())); + Ok(ReadTextFileResponse::new(&content)) + }) + } + + pub fn write_handler( + &self, + expected_path: &str, + expected_content: &str, + ) -> WriteTextFileHandler { + let calls = self.calls.clone(); + let expected_path = expected_path.to_string(); + let expected_content = expected_content.to_string(); + Arc::new(move |req: &WriteTextFileRequest| { + let path = req.path.to_str().unwrap_or(""); + if path != expected_path { + let err = format!("expected path {expected_path}, got {path}"); + calls.lock().unwrap().push(Err(err.clone())); + return Err(err); + } + if req.content != expected_content { + let err = format!("expected content {expected_content}, got {}", req.content); + calls.lock().unwrap().push(Err(err.clone())); + return Err(err); + } + calls.lock().unwrap().push(Ok(())); + Ok(WriteTextFileResponse::new()) + }) + } + + pub fn assert_called(&self) { + let calls = self.calls.lock().unwrap(); + assert!(!calls.is_empty(), "fs handler was never called"); + let errors: Vec<_> = calls.iter().filter_map(|c| c.as_ref().err()).collect(); + assert!(errors.is_empty(), "fs handler errors: {errors:?}"); + } +} + pub struct TestConnectionConfig { pub mcp_servers: Vec, pub builtins: Vec, pub goose_mode: GooseMode, pub data_root: PathBuf, pub provider_factory: Option, + pub read_text_file: Option, + pub write_text_file: Option, } impl Default for TestConnectionConfig { @@ -214,6 +284,8 @@ impl Default for TestConnectionConfig { goose_mode: GooseMode::Auto, data_root: PathBuf::new(), provider_factory: None, + read_text_file: None, + write_text_file: None, } } } diff --git a/crates/goose-acp/tests/fixtures/server.rs b/crates/goose-acp/tests/fixtures/server.rs index 53000b2a..9b59cb03 100644 --- a/crates/goose-acp/tests/fixtures/server.rs +++ b/crates/goose-acp/tests/fixtures/server.rs @@ -5,9 +5,11 @@ use super::{ use async_trait::async_trait; use goose::config::PermissionManager; use sacp::schema::{ - AuthMethod, ContentBlock, ImageContent, InitializeRequest, LoadSessionRequest, McpServer, - NewSessionRequest, PromptRequest, ProtocolVersion, RequestPermissionRequest, SessionModelState, + AuthMethod, ClientCapabilities, ContentBlock, FileSystemCapability, ImageContent, + InitializeRequest, LoadSessionRequest, McpServer, NewSessionRequest, PromptRequest, + ProtocolVersion, ReadTextFileRequest, RequestPermissionRequest, SessionModelState, SessionNotification, SessionUpdate, StopReason, TextContent, ToolCallStatus, + WriteTextFileRequest, }; use sacp::{ClientToAgent, JrConnectionCx}; use std::sync::{Arc, Mutex}; @@ -104,10 +106,20 @@ impl Connection for ClientToAgentConnection { let notify = Arc::new(Notify::new()); let permission = Arc::new(Mutex::new(PermissionDecision::Cancel)); + let mut fs_cap = FileSystemCapability::default(); + if config.read_text_file.is_some() { + fs_cap = fs_cap.read_text_file(true); + } + if config.write_text_file.is_some() { + fs_cap = fs_cap.write_text_file(true); + } + let (cx, auth_methods) = { let updates_clone = updates.clone(); let notify_clone = notify.clone(); let permission_clone = permission.clone(); + let read_handler = config.read_text_file; + let write_handler = config.write_text_file; let cx_holder: Arc>>> = Arc::new(Mutex::new(None)); @@ -147,6 +159,27 @@ impl Connection for ClientToAgentConnection { }, sacp::on_receive_request!(), ) + .on_receive_request( + async move |req: ReadTextFileRequest, request_cx, _cx| match read_handler { + Some(ref rh) => match rh(&req) { + Ok(resp) => request_cx.respond(resp), + Err(msg) => request_cx.respond_with_internal_error(msg), + }, + None => request_cx.respond_with_error(sacp::Error::method_not_found()), + }, + sacp::on_receive_request!(), + ) + .on_receive_request( + async move |req: WriteTextFileRequest, request_cx, _cx| match write_handler + { + Some(ref wh) => match wh(&req) { + Ok(resp) => request_cx.respond(resp), + Err(msg) => request_cx.respond_with_internal_error(msg), + }, + None => request_cx.respond_with_error(sacp::Error::method_not_found()), + }, + sacp::on_receive_request!(), + ) .connect_to(transport) .unwrap() .run_until({ @@ -154,7 +187,10 @@ impl Connection for ClientToAgentConnection { let auth_holder = auth_holder_clone; move |cx: JrConnectionCx| async move { let resp = cx - .send_request(InitializeRequest::new(ProtocolVersion::LATEST)) + .send_request( + InitializeRequest::new(ProtocolVersion::LATEST) + .client_capabilities(ClientCapabilities::new().fs(fs_cap)), + ) .block_task() .await .unwrap(); diff --git a/crates/goose-acp/tests/provider_test.rs b/crates/goose-acp/tests/provider_test.rs index 0ad2bcde..15d72c76 100644 --- a/crates/goose-acp/tests/provider_test.rs +++ b/crates/goose-acp/tests/provider_test.rs @@ -4,9 +4,10 @@ mod common_tests; use common_tests::fixtures::provider::ClientToProviderConnection; use common_tests::fixtures::run_test; use common_tests::{ - run_config_mcp, run_initialize_doesnt_hit_provider, run_load_model, run_model_list, - run_model_set, run_permission_persistence, run_prompt_basic, run_prompt_codemode, - run_prompt_image, run_prompt_image_attachment, run_prompt_mcp, + run_config_mcp, run_fs_read_text_file_true, run_fs_write_text_file_false, + run_fs_write_text_file_true, run_initialize_doesnt_hit_provider, run_load_model, + run_model_list, run_model_set, run_permission_persistence, run_prompt_basic, + run_prompt_codemode, run_prompt_image, run_prompt_image_attachment, run_prompt_mcp, }; #[test] @@ -14,6 +15,23 @@ fn test_provider_config_mcp() { run_test(async { run_config_mcp::().await }); } +#[test] +#[ignore = "TODO: AcpProvider does not yet send ClientCapabilities with FileSystemCapability"] +fn test_provider_fs_read_text_file_true() { + run_test(async { run_fs_read_text_file_true::().await }); +} + +#[test] +fn test_provider_fs_write_text_file_false() { + run_test(async { run_fs_write_text_file_false::().await }); +} + +#[test] +#[ignore = "TODO: AcpProvider does not yet send ClientCapabilities with FileSystemCapability"] +fn test_provider_fs_write_text_file_true() { + run_test(async { run_fs_write_text_file_true::().await }); +} + #[test] fn test_provider_initialize_doesnt_hit_provider() { run_test(async { run_initialize_doesnt_hit_provider::().await }); diff --git a/crates/goose-acp/tests/server_test.rs b/crates/goose-acp/tests/server_test.rs index 7e97e6ad..026bbf62 100644 --- a/crates/goose-acp/tests/server_test.rs +++ b/crates/goose-acp/tests/server_test.rs @@ -2,9 +2,11 @@ mod common_tests; use common_tests::fixtures::run_test; use common_tests::fixtures::server::ClientToAgentConnection; use common_tests::{ - run_config_mcp, run_initialize_doesnt_hit_provider, run_load_model, run_model_list, - run_model_set, run_permission_persistence, run_prompt_basic, run_prompt_codemode, - run_prompt_image, run_prompt_image_attachment, run_prompt_mcp, + run_config_mcp, run_fs_read_text_file_true, run_fs_write_text_file_false, + run_fs_write_text_file_true, run_initialize_doesnt_hit_provider, + run_initialize_without_provider, run_load_model, run_model_list, run_model_set, + run_permission_persistence, run_prompt_basic, run_prompt_codemode, run_prompt_image, + run_prompt_image_attachment, run_prompt_mcp, }; #[test] @@ -12,11 +14,31 @@ fn test_config_mcp() { run_test(async { run_config_mcp::().await }); } +#[test] +fn test_fs_read_text_file_true() { + run_test(async { run_fs_read_text_file_true::().await }); +} + +#[test] +fn test_fs_write_text_file_false() { + run_test(async { run_fs_write_text_file_false::().await }); +} + +#[test] +fn test_fs_write_text_file_true() { + run_test(async { run_fs_write_text_file_true::().await }); +} + #[test] fn test_initialize_doesnt_hit_provider() { run_test(async { run_initialize_doesnt_hit_provider::().await }); } +#[test] +fn test_initialize_without_provider() { + run_test(async { run_initialize_without_provider().await }); +} + #[test] fn test_load_model() { run_test(async { run_load_model::().await }); diff --git a/crates/goose-acp/tests/test_data/openai_fs_read_tool_call.txt b/crates/goose-acp/tests/test_data/openai_fs_read_tool_call.txt new file mode 100644 index 00000000..b9c05e96 --- /dev/null +++ b/crates/goose-acp/tests/test_data/openai_fs_read_tool_call.txt @@ -0,0 +1,31 @@ +data: {"id":"chatcmpl-DFS7Z5WU3Km0oi1oFGiFSCLRPlriu","object":"chat.completion.chunk","created":1772575389,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"call_ihWQp56Fq7txY7HHZJiNwzdy","type":"function","function":{"name":"read","arguments":""}}],"refusal":null},"finish_reason":null}],"usage":null,"obfuscation":"arnej9G"} + +data: {"id":"chatcmpl-DFS7Z5WU3Km0oi1oFGiFSCLRPlriu","object":"chat.completion.chunk","created":1772575389,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\""}}]},"finish_reason":null}],"usage":null,"obfuscation":"M6pPn6sMuZ"} + +data: {"id":"chatcmpl-DFS7Z5WU3Km0oi1oFGiFSCLRPlriu","object":"chat.completion.chunk","created":1772575389,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"path"}}]},"finish_reason":null}],"usage":null,"obfuscation":"7mHQMw0OR"} + +data: {"id":"chatcmpl-DFS7Z5WU3Km0oi1oFGiFSCLRPlriu","object":"chat.completion.chunk","created":1772575389,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\":"}}]},"finish_reason":null}],"usage":null,"obfuscation":"UktUfC37dd"} + +data: {"id":"chatcmpl-DFS7Z5WU3Km0oi1oFGiFSCLRPlriu","object":"chat.completion.chunk","created":1772575389,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"/"}}]},"finish_reason":null}],"usage":null,"obfuscation":"kkR18xflju"} + +data: {"id":"chatcmpl-DFS7Z5WU3Km0oi1oFGiFSCLRPlriu","object":"chat.completion.chunk","created":1772575389,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"tmp"}}]},"finish_reason":null}],"usage":null,"obfuscation":"WRyfb1apwr"} + +data: {"id":"chatcmpl-DFS7Z5WU3Km0oi1oFGiFSCLRPlriu","object":"chat.completion.chunk","created":1772575389,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"/test"}}]},"finish_reason":null}],"usage":null,"obfuscation":"8Q5olmtW"} + +data: {"id":"chatcmpl-DFS7Z5WU3Km0oi1oFGiFSCLRPlriu","object":"chat.completion.chunk","created":1772575389,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"_ac"}}]},"finish_reason":null}],"usage":null,"obfuscation":"3eLyGhC3LQ"} + +data: {"id":"chatcmpl-DFS7Z5WU3Km0oi1oFGiFSCLRPlriu","object":"chat.completion.chunk","created":1772575389,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"p"}}]},"finish_reason":null}],"usage":null,"obfuscation":"NXUnZPCSiGnn"} + +data: {"id":"chatcmpl-DFS7Z5WU3Km0oi1oFGiFSCLRPlriu","object":"chat.completion.chunk","created":1772575389,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"_read"}}]},"finish_reason":null}],"usage":null,"obfuscation":"mCuJG2UV"} + +data: {"id":"chatcmpl-DFS7Z5WU3Km0oi1oFGiFSCLRPlriu","object":"chat.completion.chunk","created":1772575389,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":".txt"}}]},"finish_reason":null}],"usage":null,"obfuscation":"KjZS3z8R5"} + +data: {"id":"chatcmpl-DFS7Z5WU3Km0oi1oFGiFSCLRPlriu","object":"chat.completion.chunk","created":1772575389,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"}"}}]},"finish_reason":null}],"usage":null,"obfuscation":"Pe4UZ56QwD"} + +data: {"id":"chatcmpl-DFS7Z5WU3Km0oi1oFGiFSCLRPlriu","object":"chat.completion.chunk","created":1772575389,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":null,"obfuscation":"5hgvg2ilskM"} + +data: {"id":"chatcmpl-DFS7Z5WU3Km0oi1oFGiFSCLRPlriu","object":"chat.completion.chunk","created":1772575389,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[],"usage":{"prompt_tokens":7010,"completion_tokens":156,"total_tokens":7166,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":128,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}},"obfuscation":"bxKDPI1dGxmTdwd"} + +data: [DONE] + + diff --git a/crates/goose-acp/tests/test_data/openai_fs_read_tool_result.txt b/crates/goose-acp/tests/test_data/openai_fs_read_tool_result.txt new file mode 100644 index 00000000..a56e349b --- /dev/null +++ b/crates/goose-acp/tests/test_data/openai_fs_read_tool_result.txt @@ -0,0 +1,21 @@ +data: {"id":"chatcmpl-DFS7cNHbE2luDLKMezfuTconLbkjx","object":"chat.completion.chunk","created":1772575392,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"finish_reason":null}],"usage":null,"obfuscation":"5Gvpc"} + +data: {"id":"chatcmpl-DFS7cNHbE2luDLKMezfuTconLbkjx","object":"chat.completion.chunk","created":1772575392,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"test"},"finish_reason":null}],"usage":null,"obfuscation":"8x6"} + +data: {"id":"chatcmpl-DFS7cNHbE2luDLKMezfuTconLbkjx","object":"chat.completion.chunk","created":1772575392,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"-read"},"finish_reason":null}],"usage":null,"obfuscation":"PM"} + +data: {"id":"chatcmpl-DFS7cNHbE2luDLKMezfuTconLbkjx","object":"chat.completion.chunk","created":1772575392,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"-content"},"finish_reason":null}],"usage":null,"obfuscation":"UxQl2cnPsJJsU4e"} + +data: {"id":"chatcmpl-DFS7cNHbE2luDLKMezfuTconLbkjx","object":"chat.completion.chunk","created":1772575392,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"-"},"finish_reason":null}],"usage":null,"obfuscation":"OIwMhu"} + +data: {"id":"chatcmpl-DFS7cNHbE2luDLKMezfuTconLbkjx","object":"chat.completion.chunk","created":1772575392,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"123"},"finish_reason":null}],"usage":null,"obfuscation":"wsSJ"} + +data: {"id":"chatcmpl-DFS7cNHbE2luDLKMezfuTconLbkjx","object":"chat.completion.chunk","created":1772575392,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"45"},"finish_reason":null}],"usage":null,"obfuscation":"2A6Mf"} + +data: {"id":"chatcmpl-DFS7cNHbE2luDLKMezfuTconLbkjx","object":"chat.completion.chunk","created":1772575392,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":null,"obfuscation":"R"} + +data: {"id":"chatcmpl-DFS7cNHbE2luDLKMezfuTconLbkjx","object":"chat.completion.chunk","created":1772575392,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[],"usage":{"prompt_tokens":7061,"completion_tokens":143,"total_tokens":7204,"prompt_tokens_details":{"cached_tokens":6784,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":128,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}},"obfuscation":"GPZ33iiMhEfX"} + +data: [DONE] + + diff --git a/crates/goose-acp/tests/test_data/openai_fs_write_tool_call.txt b/crates/goose-acp/tests/test_data/openai_fs_write_tool_call.txt new file mode 100644 index 00000000..bdeef703 --- /dev/null +++ b/crates/goose-acp/tests/test_data/openai_fs_write_tool_call.txt @@ -0,0 +1,57 @@ +data: {"id":"chatcmpl-DFS9ySulK0wZhiU0Qbcdrr7vqTth2","object":"chat.completion.chunk","created":1772575538,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"call_AmzdRa1JlDxMgwoFQ3W9Y6bf","type":"function","function":{"name":"write","arguments":""}}],"refusal":null},"finish_reason":null}],"usage":null,"obfuscation":"RgiRu2"} + +data: {"id":"chatcmpl-DFS9ySulK0wZhiU0Qbcdrr7vqTth2","object":"chat.completion.chunk","created":1772575538,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{"}}]},"finish_reason":null}],"usage":null,"obfuscation":"c0QxkX9w4RWN"} + +data: {"id":"chatcmpl-DFS9ySulK0wZhiU0Qbcdrr7vqTth2","object":"chat.completion.chunk","created":1772575538,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" \""}}]},"finish_reason":null}],"usage":null,"obfuscation":"guqW44RQ65"} + +data: {"id":"chatcmpl-DFS9ySulK0wZhiU0Qbcdrr7vqTth2","object":"chat.completion.chunk","created":1772575538,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"path"}}]},"finish_reason":null}],"usage":null,"obfuscation":"U4psai0wZ"} + +data: {"id":"chatcmpl-DFS9ySulK0wZhiU0Qbcdrr7vqTth2","object":"chat.completion.chunk","created":1772575538,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\":"}}]},"finish_reason":null}],"usage":null,"obfuscation":"VmwxuO12qp"} + +data: {"id":"chatcmpl-DFS9ySulK0wZhiU0Qbcdrr7vqTth2","object":"chat.completion.chunk","created":1772575538,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" \"/"}}]},"finish_reason":null}],"usage":null,"obfuscation":"u6VUDV4LA"} + +data: {"id":"chatcmpl-DFS9ySulK0wZhiU0Qbcdrr7vqTth2","object":"chat.completion.chunk","created":1772575538,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"tmp"}}]},"finish_reason":null}],"usage":null,"obfuscation":"oyo2Q6TTwc"} + +data: {"id":"chatcmpl-DFS9ySulK0wZhiU0Qbcdrr7vqTth2","object":"chat.completion.chunk","created":1772575538,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"/test"}}]},"finish_reason":null}],"usage":null,"obfuscation":"ra2IFNjY"} + +data: {"id":"chatcmpl-DFS9ySulK0wZhiU0Qbcdrr7vqTth2","object":"chat.completion.chunk","created":1772575538,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"_ac"}}]},"finish_reason":null}],"usage":null,"obfuscation":"67j2bDOpM3"} + +data: {"id":"chatcmpl-DFS9ySulK0wZhiU0Qbcdrr7vqTth2","object":"chat.completion.chunk","created":1772575538,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"p"}}]},"finish_reason":null}],"usage":null,"obfuscation":"tqT1vbnWapFk"} + +data: {"id":"chatcmpl-DFS9ySulK0wZhiU0Qbcdrr7vqTth2","object":"chat.completion.chunk","created":1772575538,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"_write"}}]},"finish_reason":null}],"usage":null,"obfuscation":"35yX5yg"} + +data: {"id":"chatcmpl-DFS9ySulK0wZhiU0Qbcdrr7vqTth2","object":"chat.completion.chunk","created":1772575538,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":".txt"}}]},"finish_reason":null}],"usage":null,"obfuscation":"2OmZyuL8W"} + +data: {"id":"chatcmpl-DFS9ySulK0wZhiU0Qbcdrr7vqTth2","object":"chat.completion.chunk","created":1772575538,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\","}}]},"finish_reason":null}],"usage":null,"obfuscation":"D4bVfN7m5M"} + +data: {"id":"chatcmpl-DFS9ySulK0wZhiU0Qbcdrr7vqTth2","object":"chat.completion.chunk","created":1772575538,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" \""}}]},"finish_reason":null}],"usage":null,"obfuscation":"TtPSCtq6VV"} + +data: {"id":"chatcmpl-DFS9ySulK0wZhiU0Qbcdrr7vqTth2","object":"chat.completion.chunk","created":1772575538,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"content"}}]},"finish_reason":null}],"usage":null,"obfuscation":"EqSkfr"} + +data: {"id":"chatcmpl-DFS9ySulK0wZhiU0Qbcdrr7vqTth2","object":"chat.completion.chunk","created":1772575538,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\":"}}]},"finish_reason":null}],"usage":null,"obfuscation":"ubKsA67ljm"} + +data: {"id":"chatcmpl-DFS9ySulK0wZhiU0Qbcdrr7vqTth2","object":"chat.completion.chunk","created":1772575538,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" \""}}]},"finish_reason":null}],"usage":null,"obfuscation":"H6lzd69n6V"} + +data: {"id":"chatcmpl-DFS9ySulK0wZhiU0Qbcdrr7vqTth2","object":"chat.completion.chunk","created":1772575538,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"test"}}]},"finish_reason":null}],"usage":null,"obfuscation":"v0IFOBPju"} + +data: {"id":"chatcmpl-DFS9ySulK0wZhiU0Qbcdrr7vqTth2","object":"chat.completion.chunk","created":1772575538,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"-write"}}]},"finish_reason":null}],"usage":null,"obfuscation":"jzyuAat"} + +data: {"id":"chatcmpl-DFS9ySulK0wZhiU0Qbcdrr7vqTth2","object":"chat.completion.chunk","created":1772575538,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"-content"}}]},"finish_reason":null}],"usage":null,"obfuscation":"6p3tY"} + +data: {"id":"chatcmpl-DFS9ySulK0wZhiU0Qbcdrr7vqTth2","object":"chat.completion.chunk","created":1772575538,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"-"}}]},"finish_reason":null}],"usage":null,"obfuscation":"R4PFCfWGWwQO"} + +data: {"id":"chatcmpl-DFS9ySulK0wZhiU0Qbcdrr7vqTth2","object":"chat.completion.chunk","created":1772575538,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"678"}}]},"finish_reason":null}],"usage":null,"obfuscation":"BHRNUazkpJ"} + +data: {"id":"chatcmpl-DFS9ySulK0wZhiU0Qbcdrr7vqTth2","object":"chat.completion.chunk","created":1772575538,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"90"}}]},"finish_reason":null}],"usage":null,"obfuscation":"085WWflBbh1"} + +data: {"id":"chatcmpl-DFS9ySulK0wZhiU0Qbcdrr7vqTth2","object":"chat.completion.chunk","created":1772575538,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\""}}]},"finish_reason":null}],"usage":null,"obfuscation":"zu9irR3Bf58"} + +data: {"id":"chatcmpl-DFS9ySulK0wZhiU0Qbcdrr7vqTth2","object":"chat.completion.chunk","created":1772575538,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" }"}}]},"finish_reason":null}],"usage":null,"obfuscation":"m4lW5kBYPNx"} + +data: {"id":"chatcmpl-DFS9ySulK0wZhiU0Qbcdrr7vqTth2","object":"chat.completion.chunk","created":1772575538,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":null,"obfuscation":"Ptpd2etx9L8"} + +data: {"id":"chatcmpl-DFS9ySulK0wZhiU0Qbcdrr7vqTth2","object":"chat.completion.chunk","created":1772575538,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[],"usage":{"prompt_tokens":7013,"completion_tokens":233,"total_tokens":7246,"prompt_tokens_details":{"cached_tokens":6784,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":192,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}},"obfuscation":"K1gClbYIVju8"} + +data: [DONE] + + diff --git a/crates/goose-acp/tests/test_data/openai_fs_write_tool_result.txt b/crates/goose-acp/tests/test_data/openai_fs_write_tool_result.txt new file mode 100644 index 00000000..df360031 --- /dev/null +++ b/crates/goose-acp/tests/test_data/openai_fs_write_tool_result.txt @@ -0,0 +1,41 @@ +data: {"id":"chatcmpl-DFSA1sjiFQp5MUkdJvhadSGA4nAoX","object":"chat.completion.chunk","created":1772575541,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"finish_reason":null}],"usage":null,"obfuscation":"LlUCg"} + +data: {"id":"chatcmpl-DFSA1sjiFQp5MUkdJvhadSGA4nAoX","object":"chat.completion.chunk","created":1772575541,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"Written"},"finish_reason":null}],"usage":null,"obfuscation":""} + +data: {"id":"chatcmpl-DFSA1sjiFQp5MUkdJvhadSGA4nAoX","object":"chat.completion.chunk","created":1772575541,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" to"},"finish_reason":null}],"usage":null,"obfuscation":"eOkP"} + +data: {"id":"chatcmpl-DFSA1sjiFQp5MUkdJvhadSGA4nAoX","object":"chat.completion.chunk","created":1772575541,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" /"},"finish_reason":null}],"usage":null,"obfuscation":"Wna6a"} + +data: {"id":"chatcmpl-DFSA1sjiFQp5MUkdJvhadSGA4nAoX","object":"chat.completion.chunk","created":1772575541,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"tmp"},"finish_reason":null}],"usage":null,"obfuscation":"r1VT"} + +data: {"id":"chatcmpl-DFSA1sjiFQp5MUkdJvhadSGA4nAoX","object":"chat.completion.chunk","created":1772575541,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"/test"},"finish_reason":null}],"usage":null,"obfuscation":"KI"} + +data: {"id":"chatcmpl-DFSA1sjiFQp5MUkdJvhadSGA4nAoX","object":"chat.completion.chunk","created":1772575541,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"_ac"},"finish_reason":null}],"usage":null,"obfuscation":"OV52"} + +data: {"id":"chatcmpl-DFSA1sjiFQp5MUkdJvhadSGA4nAoX","object":"chat.completion.chunk","created":1772575541,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"p"},"finish_reason":null}],"usage":null,"obfuscation":"lN8FEz"} + +data: {"id":"chatcmpl-DFSA1sjiFQp5MUkdJvhadSGA4nAoX","object":"chat.completion.chunk","created":1772575541,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"_write"},"finish_reason":null}],"usage":null,"obfuscation":"w"} + +data: {"id":"chatcmpl-DFSA1sjiFQp5MUkdJvhadSGA4nAoX","object":"chat.completion.chunk","created":1772575541,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":".txt"},"finish_reason":null}],"usage":null,"obfuscation":"mhj"} + +data: {"id":"chatcmpl-DFSA1sjiFQp5MUkdJvhadSGA4nAoX","object":"chat.completion.chunk","created":1772575541,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":":"},"finish_reason":null}],"usage":null,"obfuscation":"MLXIAD"} + +data: {"id":"chatcmpl-DFSA1sjiFQp5MUkdJvhadSGA4nAoX","object":"chat.completion.chunk","created":1772575541,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":" test"},"finish_reason":null}],"usage":null,"obfuscation":"fu"} + +data: {"id":"chatcmpl-DFSA1sjiFQp5MUkdJvhadSGA4nAoX","object":"chat.completion.chunk","created":1772575541,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"-write"},"finish_reason":null}],"usage":null,"obfuscation":"P"} + +data: {"id":"chatcmpl-DFSA1sjiFQp5MUkdJvhadSGA4nAoX","object":"chat.completion.chunk","created":1772575541,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"-content"},"finish_reason":null}],"usage":null,"obfuscation":"UnYjMuzV1tsglmv"} + +data: {"id":"chatcmpl-DFSA1sjiFQp5MUkdJvhadSGA4nAoX","object":"chat.completion.chunk","created":1772575541,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"-"},"finish_reason":null}],"usage":null,"obfuscation":"dXz2oN"} + +data: {"id":"chatcmpl-DFSA1sjiFQp5MUkdJvhadSGA4nAoX","object":"chat.completion.chunk","created":1772575541,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"678"},"finish_reason":null}],"usage":null,"obfuscation":"cBoM"} + +data: {"id":"chatcmpl-DFSA1sjiFQp5MUkdJvhadSGA4nAoX","object":"chat.completion.chunk","created":1772575541,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"90"},"finish_reason":null}],"usage":null,"obfuscation":"nCBwP"} + +data: {"id":"chatcmpl-DFSA1sjiFQp5MUkdJvhadSGA4nAoX","object":"chat.completion.chunk","created":1772575541,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":null,"obfuscation":"j"} + +data: {"id":"chatcmpl-DFSA1sjiFQp5MUkdJvhadSGA4nAoX","object":"chat.completion.chunk","created":1772575541,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[],"usage":{"prompt_tokens":7080,"completion_tokens":19,"total_tokens":7099,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}},"obfuscation":"e7"} + +data: [DONE] + + diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index 81482c83..cd265819 100644 --- a/crates/goose/src/agents/extension_manager.rs +++ b/crates/goose/src/agents/extension_manager.rs @@ -507,7 +507,6 @@ impl ExtensionManager { } } - #[cfg(test)] pub fn new_without_provider(data_dir: std::path::PathBuf) -> Self { let session_manager = Arc::new(crate::session::SessionManager::new(data_dir)); Self::new( diff --git a/crates/goose/src/agents/platform_extensions/developer/edit.rs b/crates/goose/src/agents/platform_extensions/developer/edit.rs index cc19bf0f..2ab34dc7 100644 --- a/crates/goose/src/agents/platform_extensions/developer/edit.rs +++ b/crates/goose/src/agents/platform_extensions/developer/edit.rs @@ -7,6 +7,17 @@ use serde::Deserialize; const NO_MATCH_PREVIEW_LINES: usize = 20; +#[derive(Debug, Deserialize, JsonSchema)] +pub struct FileReadParams { + /// Absolute path to the file to read. + pub path: String, + /// Line number to start reading from (1-based). + #[schemars(range(min = 1))] + pub line: Option, + /// Maximum number of lines to read. + pub limit: Option, +} + #[derive(Debug, Deserialize, JsonSchema)] pub struct FileWriteParams { pub path: String, @@ -27,6 +38,26 @@ impl EditTools { Self } + pub fn file_read_with_cwd( + &self, + params: FileReadParams, + working_dir: Option<&Path>, + ) -> CallToolResult { + let path = resolve_path(¶ms.path, working_dir); + + match fs::read_to_string(&path) { + Ok(content) => { + let content = apply_line_limit(&content, params.line, params.limit); + CallToolResult::success(vec![Content::text(content).with_priority(0.0)]) + } + Err(error) => CallToolResult::error(vec![Content::text(format!( + "Failed to read {}: {}", + params.path, error + )) + .with_priority(0.0)]), + } + } + pub fn file_write(&self, params: FileWriteParams) -> CallToolResult { self.file_write_with_cwd(params, None) } @@ -93,62 +124,27 @@ impl EditTools { } }; - let matches: Vec<_> = content.match_indices(¶ms.before).collect(); - - match matches.len() { - 0 => { - let suggestion = find_similar_context(&content, ¶ms.before); - let mut msg = "No match found for the specified text.".to_string(); - if let Some(hint) = suggestion { - msg.push_str(&format!("\n\nDid you mean:\n```\n{}\n```", hint)); - } - let preview = build_file_preview(&content, NO_MATCH_PREVIEW_LINES); - msg.push_str(&format!("\n\nFile preview:\n```\n{}\n```", preview)); - CallToolResult::error(vec![Content::text(msg).with_priority(0.0)]) + let new_content = match string_replace(&content, ¶ms.before, ¶ms.after) { + Ok(c) => c, + Err(msg) => { + return CallToolResult::error(vec![Content::text(msg).with_priority(0.0)]); } - 1 => { - let new_content = content.replacen(¶ms.before, ¶ms.after, 1); - - match fs::write(&path, &new_content) { - Ok(()) => { - let old_lines = params.before.lines().count(); - let new_lines = params.after.lines().count(); - CallToolResult::success(vec![Content::text(format!( - "Edited {} ({} lines -> {} lines)", - params.path, old_lines, new_lines - )) - .with_priority(0.0)]) - } - Err(error) => CallToolResult::error(vec![Content::text(format!( - "Failed to write {}: {}", - params.path, error - )) - .with_priority(0.0)]), - } - } - n => { - let mut msg = format!( - "Found {} matches. Please provide more context to identify a unique match:\n", - n - ); - - for (i, (pos, _)) in matches.iter().enumerate().take(2) { - let line_num = count_lines_before(&content, *pos); - let context = get_line_context(&content, line_num, 1); - msg.push_str(&format!( - "\nMatch {} (line {}):\n```\n{}\n```", - i + 1, - line_num, - context - )); - } - - if n > 2 { - msg.push_str(&format!("\n\n...and {} more", n - 2)); - } - - CallToolResult::error(vec![Content::text(msg).with_priority(0.0)]) + }; + match fs::write(&path, &new_content) { + Ok(()) => { + let old_lines = params.before.lines().count(); + let new_lines = params.after.lines().count(); + CallToolResult::success(vec![Content::text(format!( + "Edited {} ({} lines -> {} lines)", + params.path, old_lines, new_lines + )) + .with_priority(0.0)]) } + Err(error) => CallToolResult::error(vec![Content::text(format!( + "Failed to write {}: {}", + params.path, error + )) + .with_priority(0.0)]), } } } @@ -159,7 +155,64 @@ impl Default for EditTools { } } -fn resolve_path(path: &str, working_dir: Option<&Path>) -> PathBuf { +pub fn string_replace(content: &str, before: &str, after: &str) -> Result { + let matches: Vec<_> = content.match_indices(before).collect(); + + match matches.len() { + 0 => { + let suggestion = find_similar_context(content, before); + let mut msg = "No match found for the specified text.".to_string(); + if let Some(hint) = suggestion { + msg.push_str(&format!("\n\nDid you mean:\n```\n{}\n```", hint)); + } + let preview = build_file_preview(content, NO_MATCH_PREVIEW_LINES); + msg.push_str(&format!("\n\nFile preview:\n```\n{}\n```", preview)); + Err(msg) + } + 1 => Ok(content.replacen(before, after, 1)), + n => { + let mut msg = format!( + "Found {} matches. Please provide more context to identify a unique match:\n", + n + ); + + for (i, (pos, _)) in matches.iter().enumerate().take(2) { + let line_num = count_lines_before(content, *pos); + let context = get_line_context(content, line_num, 1); + msg.push_str(&format!( + "\nMatch {} (line {}):\n```\n{}\n```", + i + 1, + line_num, + context + )); + } + + if n > 2 { + msg.push_str(&format!("\n\n...and {} more", n - 2)); + } + + Err(msg) + } + } +} + +fn apply_line_limit(content: &str, line: Option, limit: Option) -> String { + if line.is_none() && limit.is_none() { + return content.to_string(); + } + let lines: Vec<&str> = content.split_inclusive('\n').collect(); + let start = line + .map(|l| (l as usize).saturating_sub(1)) + .unwrap_or(0) + .min(lines.len()); + let end = limit + .map(|l| start + l as usize) + .unwrap_or(lines.len()) + .min(lines.len()); + lines[start..end].concat() +} + +pub fn resolve_path(path: &str, working_dir: Option<&Path>) -> PathBuf { let path = PathBuf::from(path); if path.is_absolute() { path @@ -231,6 +284,7 @@ mod tests { use rmcp::model::RawContent; use std::fs; use tempfile::TempDir; + use test_case::test_case; fn setup() -> TempDir { tempfile::tempdir().unwrap() @@ -243,6 +297,58 @@ mod tests { } } + #[test_case(None, None, "line1\nline2\nline3" ; "full content")] + #[test_case(Some(2), None, "line2\nline3" ; "from line 2")] + #[test_case(None, Some(2), "line1\nline2\n" ; "limit 2")] + #[test_case(Some(2), Some(1), "line2\n" ; "line 2 limit 1")] + #[test_case(Some(99), None, "" ; "beyond eof")] + fn test_apply_line_limit(line: Option, limit: Option, expected: &str) { + assert_eq!( + apply_line_limit("line1\nline2\nline3", line, limit), + expected + ); + } + + #[test] + fn test_file_read() { + let dir = setup(); + let path = dir.path().join("read.txt"); + fs::write(&path, "line1\nline2\nline3").unwrap(); + let tools = EditTools::new(); + + let result = tools.file_read_with_cwd( + FileReadParams { + path: path.to_string_lossy().to_string(), + line: None, + limit: None, + }, + None, + ); + + assert!(!result.is_error.unwrap_or(false)); + assert_eq!(extract_text(&result), "line1\nline2\nline3"); + } + + #[test] + fn test_file_read_partial() { + let dir = setup(); + let path = dir.path().join("read.txt"); + fs::write(&path, "line1\nline2\nline3").unwrap(); + let tools = EditTools::new(); + + let result = tools.file_read_with_cwd( + FileReadParams { + path: path.to_string_lossy().to_string(), + line: Some(2), + limit: Some(1), + }, + None, + ); + + assert!(!result.is_error.unwrap_or(false)); + assert_eq!(extract_text(&result), "line2\n"); + } + #[test] fn test_file_write_new() { let dir = setup(); diff --git a/crates/goose/src/agents/platform_extensions/developer/mod.rs b/crates/goose/src/agents/platform_extensions/developer/mod.rs index ff84acca..f87555f8 100644 --- a/crates/goose/src/agents/platform_extensions/developer/mod.rs +++ b/crates/goose/src/agents/platform_extensions/developer/mod.rs @@ -6,7 +6,7 @@ use crate::agents::extension::PlatformExtensionContext; use crate::agents::mcp_client::{Error, McpClientTrait}; use anyhow::Result; use async_trait::async_trait; -use edit::{EditTools, FileEditParams, FileWriteParams}; +use edit::{EditTools, FileEditParams, FileReadParams, FileWriteParams}; use indoc::indoc; use rmcp::model::{ CallToolResult, Content, Implementation, InitializeResult, JsonObject, ListToolsResult, @@ -65,7 +65,7 @@ impl DeveloperClient { .clone() } - fn parse_args( + pub fn parse_args( arguments: Option, ) -> Result { let value = arguments @@ -74,7 +74,7 @@ impl DeveloperClient { serde_json::from_value(value).map_err(|e| format!("Failed to parse arguments: {e}")) } - fn get_tools() -> Vec { + pub(crate) fn get_tools() -> Vec { vec![ Tool::new( "write".to_string(), @@ -154,6 +154,13 @@ impl McpClientTrait for DeveloperClient { ) -> Result { let working_dir = working_dir.map(Path::new); match name { + "read" => match Self::parse_args::(arguments) { + Ok(params) => Ok(self.edit_tools.file_read_with_cwd(params, working_dir)), + Err(error) => Ok(CallToolResult::error(vec![Content::text(format!( + "Error: {error}" + )) + .with_priority(0.0)])), + }, "shell" => match Self::parse_args::(arguments) { Ok(params) => Ok(self.shell_tool.shell_with_cwd(params, working_dir).await), Err(error) => Ok(ShellTool::error_result(&format!("Error: {error}"), None)),