feat(acp): add read tool and delegate filesystem I/O to ACP clients (#7668)
Signed-off-by: Adrian Cole <adrian@tetrate.io>
This commit is contained in:
@@ -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"] }
|
||||
|
||||
@@ -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<AgentToClient>,
|
||||
session_id: &SessionId,
|
||||
path: &Path,
|
||||
line: Option<u32>,
|
||||
limit: Option<u32>,
|
||||
) -> Result<String, String> {
|
||||
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<AgentToClient>,
|
||||
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<dyn McpClientTrait>,
|
||||
pub(crate) cx: JrConnectionCx<AgentToClient>,
|
||||
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<u32>,
|
||||
) -> 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<T: serde::de::DeserializeOwned>(
|
||||
arguments: Option<rmcp::model::JsonObject>,
|
||||
) -> Result<T, String> {
|
||||
DeveloperClient::parse_args(arguments).map_err(|e| format!("Error: {e}"))
|
||||
}
|
||||
|
||||
async fn read_content(&self, path: &Path) -> Result<String, String> {
|
||||
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<rmcp::model::JsonObject>,
|
||||
working_dir: Option<&str>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
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<rmcp::model::JsonObject>,
|
||||
working_dir: Option<&str>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
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<rmcp::model::JsonObject>,
|
||||
working_dir: Option<&str>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
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<String>,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> Result<rmcp::model::ListToolsResult, McpError> {
|
||||
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<rmcp::model::JsonObject>,
|
||||
working_dir: Option<&str>,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
mod adapters;
|
||||
pub mod custom_requests;
|
||||
mod fs;
|
||||
pub mod server;
|
||||
pub mod server_factory;
|
||||
pub mod transport;
|
||||
|
||||
+309
-68
@@ -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<Mutex<HashMap<String, GooseAcpSession>>>,
|
||||
provider_factory: ProviderConstructor,
|
||||
builtins: Vec<String>,
|
||||
client_fs_capabilities: Mutex<FileSystemCapability>,
|
||||
config_dir: std::path::PathBuf,
|
||||
session_manager: Arc<SessionManager>,
|
||||
permission_manager: Arc<PermissionManager>,
|
||||
goose_mode: goose::config::GooseMode,
|
||||
disable_session_naming: bool,
|
||||
builtins: Vec<String>,
|
||||
}
|
||||
|
||||
fn mcp_server_to_extension_config(mcp_server: McpServer) -> Result<ExtensionConfig, String> {
|
||||
@@ -96,6 +100,13 @@ fn mcp_server_to_extension_config(mcp_server: McpServer) -> Result<ExtensionConf
|
||||
}
|
||||
}
|
||||
|
||||
fn get_requested_line(arguments: Option<&rmcp::model::JsonObject>) -> Option<u32> {
|
||||
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<u32>) -> ToolCallLocation {
|
||||
let mut loc = ToolCallLocation::new(path);
|
||||
if let Some(l) = line {
|
||||
@@ -105,7 +116,29 @@ fn create_tool_location(path: &str, line: Option<u32>) -> 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<Vec<ToolCallLocation>> {
|
||||
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::Value> = 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::<Vec<_>>();
|
||||
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<String>) {
|
||||
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<ExtensionConfig>) {
|
||||
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<Agent> {
|
||||
async fn create_agent_for_session(
|
||||
&self,
|
||||
cx: Option<&JrConnectionCx<AgentToClient>>,
|
||||
session_id: Option<&SessionId>,
|
||||
) -> Result<Arc<Agent>> {
|
||||
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<dyn McpClientTrait> = 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<InitializeResponse, sacp::Error> {
|
||||
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<AgentToClient>,
|
||||
args: NewSessionRequest,
|
||||
) -> Result<NewSessionResponse, sacp::Error> {
|
||||
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<AgentToClient>,
|
||||
args: LoadSessionRequest,
|
||||
) -> Result<LoadSessionResponse, sacp::Error> {
|
||||
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<AgentToClient>,
|
||||
args: PromptRequest,
|
||||
) -> Result<PromptResponse, sacp::Error> {
|
||||
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<NewSessionResponse>| 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<LoadSessionResponse>| 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<String>) -> 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<Vec<String>, 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<rmcp::model::JsonObject>) -> Option<u32> {
|
||||
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<u32>)>::new()
|
||||
; "non file tool returns empty"
|
||||
)]
|
||||
fn test_extract_tool_locations(
|
||||
request: ToolRequest,
|
||||
response: ToolResponse,
|
||||
) -> Vec<(PathBuf, Option<u32>)> {
|
||||
extract_tool_locations(&request, &response)
|
||||
.into_iter()
|
||||
.map(|loc| (loc.path, loc.line))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn response_with_meta(meta: Option<serde_json::Value>) -> 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<Vec<(PathBuf, Option<u32>)>> {
|
||||
extract_locations_from_meta(&response)
|
||||
.map(|locs| locs.into_iter().map(|loc| (loc.path, loc.line)).collect())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<C: Connection>() {
|
||||
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<C: Connection>() {
|
||||
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<C: Connection>() {
|
||||
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<C: Connection>() {
|
||||
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<C: Connection>() {
|
||||
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<C: Connection>() {
|
||||
.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<C: Connection>() {
|
||||
let expected_session_id = ExpectedSessionId::default();
|
||||
let openai = OpenAiFixture::new(
|
||||
|
||||
+75
-3
@@ -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<ProviderConstructor>,
|
||||
) -> (DuplexTransport, JoinHandle<()>, Arc<PermissionManager>) {
|
||||
@@ -198,12 +200,80 @@ pub struct TestOutput {
|
||||
pub tool_status: Option<ToolCallStatus>,
|
||||
}
|
||||
|
||||
type ReadTextFileHandler =
|
||||
Arc<dyn Fn(&ReadTextFileRequest) -> Result<ReadTextFileResponse, String> + Send + Sync>;
|
||||
type WriteTextFileHandler =
|
||||
Arc<dyn Fn(&WriteTextFileRequest) -> Result<WriteTextFileResponse, String> + Send + Sync>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FsFixture {
|
||||
calls: Arc<Mutex<Vec<Result<(), String>>>>,
|
||||
}
|
||||
|
||||
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<McpServer>,
|
||||
pub builtins: Vec<String>,
|
||||
pub goose_mode: GooseMode,
|
||||
pub data_root: PathBuf,
|
||||
pub provider_factory: Option<ProviderConstructor>,
|
||||
pub read_text_file: Option<ReadTextFileHandler>,
|
||||
pub write_text_file: Option<WriteTextFileHandler>,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+39
-3
@@ -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<Mutex<Option<JrConnectionCx<ClientToAgent>>>> =
|
||||
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<ClientToAgent>| 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();
|
||||
|
||||
@@ -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::<ClientToProviderConnection>().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::<ClientToProviderConnection>().await });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_fs_write_text_file_false() {
|
||||
run_test(async { run_fs_write_text_file_false::<ClientToProviderConnection>().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::<ClientToProviderConnection>().await });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_initialize_doesnt_hit_provider() {
|
||||
run_test(async { run_initialize_doesnt_hit_provider::<ClientToProviderConnection>().await });
|
||||
|
||||
@@ -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::<ClientToAgentConnection>().await });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fs_read_text_file_true() {
|
||||
run_test(async { run_fs_read_text_file_true::<ClientToAgentConnection>().await });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fs_write_text_file_false() {
|
||||
run_test(async { run_fs_write_text_file_false::<ClientToAgentConnection>().await });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fs_write_text_file_true() {
|
||||
run_test(async { run_fs_write_text_file_true::<ClientToAgentConnection>().await });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_initialize_doesnt_hit_provider() {
|
||||
run_test(async { run_initialize_doesnt_hit_provider::<ClientToAgentConnection>().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::<ClientToAgentConnection>().await });
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<u32>,
|
||||
/// Maximum number of lines to read.
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[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<String, String> {
|
||||
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<u32>, limit: Option<u32>) -> 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<u32>, limit: Option<u32>, 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();
|
||||
|
||||
@@ -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<T: serde::de::DeserializeOwned>(
|
||||
pub fn parse_args<T: serde::de::DeserializeOwned>(
|
||||
arguments: Option<JsonObject>,
|
||||
) -> Result<T, String> {
|
||||
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<Tool> {
|
||||
pub(crate) fn get_tools() -> Vec<Tool> {
|
||||
vec![
|
||||
Tool::new(
|
||||
"write".to_string(),
|
||||
@@ -154,6 +154,13 @@ impl McpClientTrait for DeveloperClient {
|
||||
) -> Result<CallToolResult, Error> {
|
||||
let working_dir = working_dir.map(Path::new);
|
||||
match name {
|
||||
"read" => match Self::parse_args::<FileReadParams>(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::<ShellParams>(arguments) {
|
||||
Ok(params) => Ok(self.shell_tool.shell_with_cwd(params, working_dir).await),
|
||||
Err(error) => Ok(ShellTool::error_result(&format!("Error: {error}"), None)),
|
||||
|
||||
Reference in New Issue
Block a user