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:
Adrian Cole
2026-03-11 21:21:00 +08:00
committed by GitHub
parent 913537d540
commit cb4d99d303
16 changed files with 1189 additions and 140 deletions
+1
View File
@@ -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"] }
+248
View File
@@ -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(&params.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", &params.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(&params.path, working_dir.map(Path::new));
match acp_write_text_file(&self.cx, &self.session_id, &path, &params.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", &params.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(&params.path, working_dir.map(Path::new));
let content = match self.read_content(&path).await {
Ok(c) => c,
Err(e) => return Ok(fail("read", &params.path, e)),
};
let new_content = match string_replace(&content, &params.before, &params.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", &params.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()
}
}
+1
View File
@@ -2,6 +2,7 @@
mod adapters;
pub mod custom_requests;
mod fs;
pub mod server;
pub mod server_factory;
pub mod transport;
+309 -68
View File
@@ -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())
}
}
+149 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+21 -3
View File
@@ -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 });
+25 -3
View File
@@ -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(&params.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(&params.before).collect();
match matches.len() {
0 => {
let suggestion = find_similar_context(&content, &params.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, &params.before, &params.after) {
Ok(c) => c,
Err(msg) => {
return CallToolResult::error(vec![Content::text(msg).with_priority(0.0)]);
}
1 => {
let new_content = content.replacen(&params.before, &params.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)),