refactor(acp): extract tool call handling from server (#10574)

This commit is contained in:
Lifei Zhou
2026-07-21 10:58:47 +10:00
committed by GitHub
parent 7f9bd274dc
commit 076da90e89
11 changed files with 1623 additions and 1213 deletions
+7 -11
View File
@@ -1,3 +1,4 @@
use crate::acp::tool_call_notifier::ToolCallNotifier;
use crate::acp::tools::AcpAwareToolMeta;
use crate::agents::mcp_client::{Error as McpError, McpClientTrait};
use crate::agents::platform_extensions::developer::edit::{
@@ -7,9 +8,9 @@ use crate::agents::platform_extensions::developer::shell::{ShellParams, OUTPUT_L
use crate::agents::platform_extensions::developer::DeveloperClient;
use agent_client_protocol::schema::v1::{
CreateTerminalRequest, Diff, EnvVariable, KillTerminalRequest, ReadTextFileRequest,
ReleaseTerminalRequest, SessionId, SessionNotification, SessionUpdate, Terminal,
TerminalOutputRequest, ToolCallContent, ToolCallId, ToolCallLocation, ToolCallUpdate,
ToolCallUpdateFields, ToolKind, WaitForTerminalExitRequest, WriteTextFileRequest,
ReleaseTerminalRequest, SessionId, Terminal, TerminalOutputRequest, ToolCallContent,
ToolCallId, ToolCallLocation, ToolCallUpdate, ToolCallUpdateFields, ToolKind,
WaitForTerminalExitRequest, WriteTextFileRequest,
};
use agent_client_protocol::{Client, ConnectionTo};
use agent_client_protocol_schema::v1::TerminalId;
@@ -64,6 +65,7 @@ pub(crate) struct AcpTools {
pub(crate) inner: Arc<dyn McpClientTrait>,
pub(crate) cx: ConnectionTo<Client>,
pub(crate) session_id: SessionId,
pub(crate) tool_call_notifier: ToolCallNotifier,
pub(crate) fs_read: bool,
pub(crate) fs_write: bool,
pub(crate) terminal: bool,
@@ -107,14 +109,8 @@ impl AcpTools {
fn update_tool_call(&self, ctx: &crate::agents::ToolCallContext, fields: ToolCallUpdateFields) {
if let Some(ref req_id) = ctx.tool_call_request_id {
let _ = self
.cx
.send_notification(SessionNotification::new(
self.session_id.clone(),
SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
ToolCallId::new(req_id.clone()),
fields,
)),
))
.tool_call_notifier
.send_update(ToolCallUpdate::new(ToolCallId::new(req_id.clone()), fields))
.inspect_err(|e| tracing::error!("error updating tool call with client: {}", e));
}
}
+1
View File
@@ -5,6 +5,7 @@ mod provider;
mod response_builder;
pub mod server;
pub mod server_factory;
pub(crate) mod tool_call_notifier;
pub(crate) mod tools;
pub mod transport;
File diff suppressed because it is too large Load Diff
+12 -40
View File
@@ -1,3 +1,8 @@
use super::tool_calls::conversion::{
extract_tool_call_update_meta, pending_tool_call_from_request,
tool_call_update_fields_from_response,
};
use super::tool_calls::enrichment::with_tool_chain_summary_meta;
use super::*;
fn replay_audience_annotations(audience: &[Role]) -> Annotations {
@@ -33,6 +38,7 @@ fn replay_conversation_to_client(
) -> Result<HashMap<String, crate::conversation::message::ToolRequest>, agent_client_protocol::Error>
{
let session_id = SessionId::new(session.id.clone());
let tool_call_notifier = ToolCallNotifier::new(cx, &session_id);
let sid = sid_short(session_id.0.as_ref());
let messages = session
@@ -90,44 +96,13 @@ fn replay_conversation_to_client(
.tool_call
.meta(merge_replay_message_meta(meta, message));
cx.send_notification(SessionNotification::new(
session_id.clone(),
SessionUpdate::ToolCall(tool_call),
))?;
tool_call_notifier.send_initial(tool_call)?;
}
MessageContent::ToolResponse(tool_response) => {
let status = match &tool_response.tool_result {
Ok(result) if result.is_error == Some(true) => ToolCallStatus::Failed,
Ok(_) => ToolCallStatus::Completed,
Err(_) => ToolCallStatus::Failed,
};
let mut fields = ToolCallUpdateFields::new().status(status);
if let Some(raw_output) = extract_tool_raw_output(&tool_response.tool_result) {
fields = fields.raw_output(raw_output);
}
if !tool_response
.tool_result
.as_ref()
.is_ok_and(|r| r.is_acp_aware())
{
let content = build_tool_call_content(&tool_response.tool_result);
fields = fields.content(content);
let locations =
extract_locations_from_meta(tool_response).unwrap_or_else(|| {
if let Some(tool_request) =
replay_tool_requests.get(&tool_response.id)
{
extract_tool_locations(tool_request, tool_response)
} else {
Vec::new()
}
});
if !locations.is_empty() {
fields = fields.locations(locations);
}
}
let fields = tool_call_update_fields_from_response(
tool_response,
replay_tool_requests.get(&tool_response.id),
);
let update =
ToolCallUpdate::new(ToolCallId::new(tool_response.id.clone()), fields)
@@ -135,10 +110,7 @@ fn replay_conversation_to_client(
extract_tool_call_update_meta(tool_response),
message,
));
cx.send_notification(SessionNotification::new(
session_id.clone(),
SessionUpdate::ToolCallUpdate(update),
))?;
tool_call_notifier.send_update(update)?;
}
MessageContent::Thinking(thinking) => {
cx.send_notification(SessionNotification::new(
@@ -0,0 +1,148 @@
use std::collections::HashMap;
use std::sync::Arc;
/// A run of consecutive ToolRequest blocks within one assistant message,
/// tracked by `GooseAcpSession::chain_membership`. Used to drive a single
/// LLM summary for the whole run once every step has a recorded ToolResponse.
#[derive(Debug, Clone)]
pub(crate) struct ToolChain {
/// Tool call ids in document order. Always `len() >= 2`.
pub(crate) ids: Vec<String>,
/// The message_id of the assistant message containing these tool calls.
/// Used to persist chain summaries back to the messages table.
pub(crate) message_id: String,
}
/// If `buffer` holds a multi-tool run (≥ 2 tool requests), (re)register a
/// [`ToolChain`] in `chain_membership` anchored on the **first** tool's
/// message_id (the row `SessionManager::update_tool_request_meta` will patch
/// when persisting the LLM-generated summary). Does **not** clear the buffer
/// — chains can grow as more tools arrive (sequential tool use), so callers
/// keep accumulating and re-registering with the larger set of ids.
///
/// The buffer contains `(tool_call_id, message_id)` pairs in arrival order,
/// fed by the prompt stream loop. Sequential tool use (Bedrock/Anthropic)
/// interleaves request → response → request → response across separate
/// `AgentEvent::Message` events, so a per-event view would only see length-1
/// chains and miss the run. Tool responses are chain-neutral (they don't
/// split the run); only non-tool content (text, thinking, image, etc.) does,
/// matching the frontend's `groupContentSections` behavior.
pub(crate) fn extend_chain_membership(
buffer: &[(String, String)],
chain_membership: &mut HashMap<String, Arc<ToolChain>>,
) {
if buffer.len() >= 2 {
let ids: Vec<String> = buffer.iter().map(|(id, _)| id.clone()).collect();
let message_id = buffer[0].1.clone();
let chain = Arc::new(ToolChain {
ids: ids.clone(),
message_id,
});
for id in ids {
chain_membership.insert(id, chain.clone());
}
}
}
#[cfg(test)]
mod tests {
mod extend_chain_membership {
use super::super::{extend_chain_membership, ToolChain};
use std::collections::HashMap;
use std::sync::Arc;
fn buf_entry(tool_id: &str, msg_id: &str) -> (String, String) {
(tool_id.to_string(), msg_id.to_string())
}
#[test]
fn skips_singleton_and_leaves_buffer() {
let mut membership: HashMap<String, Arc<ToolChain>> = HashMap::new();
let buffer = vec![buf_entry("a", "row_1")];
extend_chain_membership(&buffer, &mut membership);
assert_eq!(buffer.len(), 1, "buffer is left intact for caller");
assert!(
membership.is_empty(),
"single-tool runs should not register a chain",
);
}
#[test]
fn registers_each_id_against_shared_chain() {
let mut membership: HashMap<String, Arc<ToolChain>> = HashMap::new();
let buffer = vec![
buf_entry("a", "row_first"),
buf_entry("b", "row_second"),
buf_entry("c", "row_third"),
];
extend_chain_membership(&buffer, &mut membership);
assert_eq!(membership.len(), 3);
let chain_a = membership.get("a").expect("a registered");
let chain_b = membership.get("b").expect("b registered");
let chain_c = membership.get("c").expect("c registered");
assert!(
Arc::ptr_eq(chain_a, chain_b) && Arc::ptr_eq(chain_b, chain_c),
"every id in the run must point at the same ToolChain Arc",
);
assert_eq!(
chain_a.ids,
vec!["a".to_string(), "b".to_string(), "c".to_string()],
);
}
#[test]
fn anchors_on_first_row_for_split_messages() {
// Sequential tool use (Bedrock/Anthropic) emits each tool request as
// its own assistant message, with the tool response interleaved in
// between. The chain should still form, anchored on the *first*
// tool's row id so `update_tool_request_meta` can find that
// ToolRequest when persisting the summary.
let mut membership: HashMap<String, Arc<ToolChain>> = HashMap::new();
let buffer = vec![
buf_entry("toolu_bdrk_1", "row_for_tool_1"),
buf_entry("toolu_bdrk_2", "row_for_tool_2"),
];
extend_chain_membership(&buffer, &mut membership);
let chain = membership
.get("toolu_bdrk_1")
.expect("first tool registered");
assert_eq!(
chain.ids,
vec!["toolu_bdrk_1".to_string(), "toolu_bdrk_2".to_string()],
);
let chain_via_second = membership
.get("toolu_bdrk_2")
.expect("second tool registered");
assert!(Arc::ptr_eq(chain, chain_via_second));
}
#[test]
fn grows_chain_as_more_requests_arrive() {
// The streaming loop re-registers eagerly each time a new request
// arrives, so a chain that started at length 2 must grow to include
// a third tool whose response is yet to come. Both the original
// members and the new member must point at the new (extended) chain.
let mut membership: HashMap<String, Arc<ToolChain>> = HashMap::new();
let mut buffer = vec![buf_entry("a", "row_1"), buf_entry("b", "row_2")];
extend_chain_membership(&buffer, &mut membership);
buffer.push(buf_entry("c", "row_3"));
extend_chain_membership(&buffer, &mut membership);
let chain_a = membership.get("a").expect("a present");
let chain_b = membership.get("b").expect("b present");
let chain_c = membership.get("c").expect("c present");
assert!(Arc::ptr_eq(chain_a, chain_b) && Arc::ptr_eq(chain_b, chain_c));
assert_eq!(
chain_a.ids,
vec!["a".to_string(), "b".to_string(), "c".to_string()],
);
}
}
}
@@ -0,0 +1,799 @@
use crate::acp::tools::AcpAwareToolMeta;
use crate::agents::extension_manager::TRUSTED_TOOL_UPDATE_META_KEY;
use crate::conversation::message::{ToolRequest, ToolResponse};
use crate::mcp_utils::ToolResult;
use agent_client_protocol::schema::v1::{
BlobResourceContents, Content, ContentBlock, EmbeddedResource, EmbeddedResourceResource,
ImageContent, Meta, TextContent, TextResourceContents, ToolCall, ToolCallContent, ToolCallId,
ToolCallLocation, ToolCallStatus, ToolCallUpdateFields,
};
use rmcp::model::{CallToolResult, RawContent, ResourceContents};
pub(crate) struct PendingToolCall {
pub(crate) tool_call: ToolCall,
pub(crate) identity_meta: Option<Meta>,
pub(crate) fallback_title: String,
}
pub(crate) fn format_tool_name(tool_name: &str) -> String {
if let Some((extension, tool)) = tool_name.split_once("__") {
format!(
"{}: {}",
extension.replace('_', " "),
tool.replace('_', " ")
)
} else {
tool_name.replace('_', " ")
}
}
/// Build a short fallback title from the tool name and arguments by extracting
/// the most useful value (file path, command, query, url, etc.).
fn summarize_tool_call(tool_name: &str, arguments: Option<&serde_json::Value>) -> String {
let base = format_tool_name(tool_name);
let detail = arguments.and_then(|args| {
let obj = args.as_object()?;
let keys = [
"path", "file", "command", "query", "url", "uri", "name", "pattern", "source",
];
for key in &keys {
if let Some(v) = obj.get(*key) {
let s = match v {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
if !s.is_empty() {
let first_line = s.lines().next().unwrap_or(&s);
if first_line.len() > 60 {
return Some(format!("{}", crate::utils::safe_truncate(first_line, 57)));
}
return Some(first_line.to_string());
}
}
}
None
});
match detail {
Some(d) => format!("{base} · {d}"),
None => base,
}
}
pub(crate) fn tool_call_identity_meta(tool_request: &ToolRequest) -> Option<Meta> {
let tool_call = tool_request.tool_call.as_ref().ok()?;
let tool_name = tool_call.name.to_string();
let extension_name = tool_request
.tool_meta
.as_ref()
.and_then(|meta| meta.get("goose_extension"))
.and_then(serde_json::Value::as_str)
.map(ToString::to_string)
.or_else(|| {
tool_name
.split_once("__")
.map(|(extension_name, _)| extension_name.to_string())
});
let mut tool_call_meta = serde_json::Map::new();
tool_call_meta.insert("toolName".to_string(), serde_json::Value::String(tool_name));
if let Some(extension_name) = extension_name {
tool_call_meta.insert(
"extensionName".to_string(),
serde_json::Value::String(extension_name),
);
}
let mut goose_meta = serde_json::Map::new();
goose_meta.insert(
"toolCall".to_string(),
serde_json::Value::Object(tool_call_meta),
);
let mut meta = serde_json::Map::new();
meta.insert("goose".to_string(), serde_json::Value::Object(goose_meta));
Some(meta)
}
pub(crate) fn pending_tool_call_from_request(tool_request: &ToolRequest) -> PendingToolCall {
let tool_name = match &tool_request.tool_call {
Ok(tool_call) => tool_call.name.to_string(),
Err(_) => "error".to_string(),
};
let args_value = tool_request
.tool_call
.as_ref()
.ok()
.and_then(|tc| tc.arguments.as_ref())
.map(|a| serde_json::Value::Object(a.clone()));
let fallback_title = summarize_tool_call(&tool_name, args_value.as_ref());
let identity_meta = tool_call_identity_meta(tool_request);
// Prefer the persisted LLM-generated title when available so replay (and
// any subsequent live initial ToolCall after the title task has already
// resolved) emits the nice title up front, with no flash of the
// deterministic fallback.
let initial_title = tool_request
.persisted_title()
.map(|s| s.to_string())
.unwrap_or_else(|| fallback_title.clone());
let mut tool_call = ToolCall::new(ToolCallId::new(tool_request.id.clone()), initial_title)
.status(ToolCallStatus::Pending);
if let Some(args) = args_value {
tool_call = tool_call.raw_input(args);
}
PendingToolCall {
tool_call,
identity_meta,
fallback_title,
}
}
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 is_developer_file_tool(tool_name: &str) -> bool {
matches!(tool_name, "read" | "write" | "edit")
}
fn extract_locations_from_meta(tool_response: &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(ToolCallLocation::new(path).line(line))
})
.collect::<Vec<_>>();
if locations.is_empty() {
None
} else {
Some(locations)
}
}
fn extract_tool_locations(
tool_request: &ToolRequest,
tool_response: &ToolResponse,
) -> Vec<ToolCallLocation> {
let mut locations = Vec::new();
if let Ok(tool_call) = &tool_request.tool_call {
if !is_developer_file_tool(tool_call.name.as_ref()) {
return locations;
}
let tool_name = tool_call.name.as_ref();
let path_str = tool_call
.arguments
.as_ref()
.and_then(|args| args.get("path"))
.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(ToolCallLocation::new(path_str).line(line));
return locations;
}
if matches!(tool_name, "write" | "edit") {
locations.push(ToolCallLocation::new(path_str).line(1));
return locations;
}
let command = tool_call
.arguments
.as_ref()
.and_then(|args| args.get("command"))
.and_then(|c| c.as_str());
if let Ok(result) = &tool_response.tool_result {
for content in &result.content {
if let RawContent::Text(text_content) = &content.raw {
let text = &text_content.text;
match command {
Some("view") => {
let line = extract_view_line_range(text)
.map(|range| range.0 as u32)
.or(Some(1));
locations.push(ToolCallLocation::new(path_str).line(line));
}
Some("str_replace") | Some("insert") => {
let line = extract_first_line_number(text)
.map(|l| l as u32)
.or(Some(1));
locations.push(ToolCallLocation::new(path_str).line(line));
}
Some("write") => {
locations.push(ToolCallLocation::new(path_str).line(1));
}
_ => {
locations.push(ToolCallLocation::new(path_str).line(1));
}
}
break;
}
}
}
if locations.is_empty() {
locations.push(ToolCallLocation::new(path_str).line(1));
}
}
}
locations
}
fn extract_view_line_range(text: &str) -> Option<(usize, usize)> {
let re = regex::Regex::new(r"\(lines (\d+)-(\d+|end)\)").ok()?;
if let Some(caps) = re.captures(text) {
let start = caps.get(1)?.as_str().parse::<usize>().ok()?;
let end = if caps.get(2)?.as_str() == "end" {
start
} else {
caps.get(2)?.as_str().parse::<usize>().ok()?
};
return Some((start, end));
}
None
}
fn extract_first_line_number(text: &str) -> Option<usize> {
let re = regex::Regex::new(r"```[^\n]*\n(\d+):").ok()?;
if let Some(caps) = re.captures(text) {
return caps.get(1)?.as_str().parse::<usize>().ok();
}
None
}
pub(crate) fn extract_tool_call_update_meta(tool_response: &ToolResponse) -> Option<Meta> {
let tool_result = tool_response.tool_result.as_ref().ok()?;
let goose_meta = tool_result
.meta
.as_ref()?
.0
.get(TRUSTED_TOOL_UPDATE_META_KEY)?
.clone();
let mut meta_map = serde_json::Map::new();
meta_map.insert("goose".to_string(), goose_meta);
Some(meta_map)
}
fn build_tool_call_content(tool_result: &ToolResult<CallToolResult>) -> Vec<ToolCallContent> {
match tool_result {
Ok(result) => result
.content
.iter()
.filter_map(|content| match &content.raw {
RawContent::Text(val) => Some(ToolCallContent::Content(Content::new(
ContentBlock::Text(TextContent::new(val.text.clone())),
))),
RawContent::Image(val) => Some(ToolCallContent::Content(Content::new(
ContentBlock::Image(ImageContent::new(val.data.clone(), val.mime_type.clone())),
))),
RawContent::Resource(val) => {
let resource = match &val.resource {
ResourceContents::TextResourceContents {
mime_type,
text,
uri,
..
} => EmbeddedResourceResource::TextResourceContents(
TextResourceContents::new(text.clone(), uri.clone())
.mime_type(mime_type.clone()),
),
ResourceContents::BlobResourceContents {
mime_type,
blob,
uri,
..
} => EmbeddedResourceResource::BlobResourceContents(
BlobResourceContents::new(blob.clone(), uri.clone())
.mime_type(mime_type.clone()),
),
};
Some(ToolCallContent::Content(Content::new(
ContentBlock::Resource(EmbeddedResource::new(resource)),
)))
}
RawContent::Audio(_) | RawContent::ResourceLink(_) => None,
})
.collect(),
Err(error) => vec![ToolCallContent::Content(Content::new(ContentBlock::Text(
TextContent::new(error.message.to_string()),
)))],
}
}
fn extract_tool_raw_output(tool_result: &ToolResult<CallToolResult>) -> Option<serde_json::Value> {
tool_result
.as_ref()
.ok()
.and_then(|result| result.structured_content.clone())
}
pub(crate) fn tool_call_update_fields_from_response(
tool_response: &ToolResponse,
tool_request: Option<&ToolRequest>,
) -> ToolCallUpdateFields {
let is_failed = match &tool_response.tool_result {
Ok(result) => result.is_error == Some(true),
Err(_) => true,
};
let status = if is_failed {
ToolCallStatus::Failed
} else {
ToolCallStatus::Completed
};
let mut fields = ToolCallUpdateFields::new().status(status);
if let Some(raw_output) = extract_tool_raw_output(&tool_response.tool_result) {
fields = fields.raw_output(raw_output);
}
let is_acp_aware = tool_response
.tool_result
.as_ref()
.is_ok_and(|result| result.is_acp_aware());
if is_failed || !is_acp_aware {
fields = fields.content(build_tool_call_content(&tool_response.tool_result));
}
if !is_acp_aware {
let locations = extract_locations_from_meta(tool_response).unwrap_or_else(|| {
tool_request
.map(|request| extract_tool_locations(request, tool_response))
.unwrap_or_default()
});
if !locations.is_empty() {
fields = fields.locations(locations);
}
}
fields
}
#[cfg(test)]
mod tests {
use super::*;
use rmcp::model::{CallToolRequestParams, Content as RmcpContent};
use std::path::PathBuf;
use test_case::test_case;
mod format_tool_name {
use super::*;
#[test]
fn with_extension() {
assert_eq!(format_tool_name("developer__edit"), "developer: edit");
assert_eq!(
format_tool_name("platform__manage_extensions"),
"platform: manage extensions"
);
assert_eq!(format_tool_name("todo__write"), "todo: write");
}
#[test]
fn without_extension() {
assert_eq!(format_tool_name("simple_tool"), "simple tool");
assert_eq!(format_tool_name("another_name"), "another name");
assert_eq!(format_tool_name("single"), "single");
}
}
mod summarize_tool_call {
use super::*;
#[test]
fn no_args() {
assert_eq!(
summarize_tool_call("developer__shell", None),
"developer: shell"
);
}
#[test]
fn with_path() {
let args = serde_json::json!({"path": "/src/main.rs", "content": "fn main() {}"});
assert_eq!(
summarize_tool_call("developer__edit", Some(&args)),
"developer: edit · /src/main.rs"
);
}
#[test]
fn with_command() {
let args = serde_json::json!({"command": "cargo build"});
assert_eq!(
summarize_tool_call("developer__shell", Some(&args)),
"developer: shell · cargo build"
);
}
#[test]
fn long_value_is_truncated() {
let long_path = "a".repeat(80);
let args = serde_json::json!({"path": long_path});
let result = summarize_tool_call("developer__read_file", Some(&args));
assert!(result.ends_with('…'));
assert!(result.len() < 90);
}
}
#[test]
fn test_tool_call_identity_meta_uses_goose_extension_metadata() {
let request = ToolRequest {
id: "req_1".to_string(),
tool_call: Ok(CallToolRequestParams::new("context7__query-docs")),
metadata: None,
tool_meta: Some(serde_json::json!({"goose_extension": "context7"})),
};
let meta = tool_call_identity_meta(&request).expect("expected metadata");
assert_eq!(
meta.get("goose"),
Some(&serde_json::json!({
"toolCall": {
"toolName": "context7__query-docs",
"extensionName": "context7",
},
})),
);
}
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())
}
mod extract_tool_call_update_meta {
use super::*;
#[test]
fn ignores_untrusted_goose_meta() {
let response = response_with_meta(Some(serde_json::json!({
"goose": {
"mcpApp": {
"resourceUri": "ui://spoofed/app",
},
},
})));
assert_eq!(extract_tool_call_update_meta(&response), None);
}
#[test]
fn uses_trusted_meta_only() {
let response = response_with_meta(Some(serde_json::json!({
"goose": {
"mcpApp": {
"resourceUri": "ui://spoofed/app",
},
},
TRUSTED_TOOL_UPDATE_META_KEY: {
"mcpApp": {
"resourceUri": "ui://trusted/app",
"extensionName": "weather",
"toolName": "weather__render",
},
},
})));
let extracted =
extract_tool_call_update_meta(&response).expect("expected trusted meta");
assert_eq!(
extracted.get("goose"),
Some(&serde_json::json!({
"mcpApp": {
"resourceUri": "ui://trusted/app",
"extensionName": "weather",
"toolName": "weather__render",
},
})),
);
}
}
#[test]
fn test_extract_tool_raw_output_preserves_structured_content() {
let mut result = CallToolResult::success(vec![RmcpContent::text("fallback")]);
result.structured_content = Some(serde_json::json!({
"restaurants": [
{
"name": "Coffee Shop",
"unitToken": "unit-1",
},
],
}));
assert_eq!(
extract_tool_raw_output(&Ok(result)),
Some(serde_json::json!({
"restaurants": [
{
"name": "Coffee Shop",
"unitToken": "unit-1",
},
],
})),
);
}
fn response_from_tool_result(tool_result: ToolResult<CallToolResult>) -> ToolResponse {
ToolResponse {
id: "req_1".to_string(),
tool_result,
metadata: None,
}
}
fn write_request(path: &str) -> ToolRequest {
ToolRequest {
id: "req_1".to_string(),
tool_call: Ok(
CallToolRequestParams::new("write").with_arguments(json_object(vec![
("path", serde_json::json!(path)),
("content", serde_json::json!("updated")),
])),
),
metadata: None,
tool_meta: None,
}
}
fn first_tool_call_text(fields: &ToolCallUpdateFields) -> Option<&str> {
fields.content.as_ref()?.iter().find_map(|content| {
let ToolCallContent::Content(content) = content else {
return None;
};
let ContentBlock::Text(text) = &content.content else {
return None;
};
Some(text.text.as_str())
})
}
mod tool_call_update_fields_from_response {
use super::*;
#[test]
fn includes_ordinary_success_details() {
let raw_output = serde_json::json!({ "changed": true });
let mut result = CallToolResult::success(vec![RmcpContent::text("write completed")]);
result.structured_content = Some(raw_output.clone());
let response = response_from_tool_result(Ok(result));
let request = write_request("/tmp/request.txt");
let fields = tool_call_update_fields_from_response(&response, Some(&request));
assert_eq!(fields.status, Some(ToolCallStatus::Completed));
assert_eq!(fields.raw_output, Some(raw_output));
assert_eq!(first_tool_call_text(&fields), Some("write completed"));
let locations = fields.locations.as_deref().expect("expected location");
assert_eq!(locations.len(), 1);
assert_eq!(locations[0].path, PathBuf::from("/tmp/request.txt"));
assert_eq!(locations[0].line, Some(1));
}
#[test]
fn includes_ordinary_error_content() {
let response =
response_from_tool_result(Ok(CallToolResult::error(vec![RmcpContent::text(
"write failed",
)])));
let fields = tool_call_update_fields_from_response(&response, None);
assert_eq!(fields.status, Some(ToolCallStatus::Failed));
assert_eq!(first_tool_call_text(&fields), Some("write failed"));
assert!(fields.locations.is_none());
}
#[test]
fn suppresses_acp_aware_success_details() {
let raw_output = serde_json::json!({ "changed": true });
let mut result = CallToolResult::success(vec![RmcpContent::text("write completed")]);
result.structured_content = Some(raw_output.clone());
let response = response_from_tool_result(Ok(result.with_acp_aware_meta()));
let request = write_request("/tmp/request.txt");
let fields = tool_call_update_fields_from_response(&response, Some(&request));
assert_eq!(fields.status, Some(ToolCallStatus::Completed));
assert_eq!(fields.raw_output, Some(raw_output));
assert!(fields.content.is_none());
assert!(fields.locations.is_none());
}
#[test]
fn prefers_explicit_location() {
let response = response_with_meta(Some(serde_json::json!({
"tool_locations": [{ "path": "/tmp/response.txt", "line": 7 }]
})));
let request = write_request("/tmp/request.txt");
let fields = tool_call_update_fields_from_response(&response, Some(&request));
let locations = fields.locations.as_deref().expect("expected location");
assert_eq!(locations.len(), 1);
assert_eq!(locations[0].path, PathBuf::from("/tmp/response.txt"));
assert_eq!(locations[0].line, Some(7));
}
#[test]
fn includes_acp_aware_error_content() {
let result = CallToolResult::error(vec![RmcpContent::text("write failed")])
.with_acp_aware_meta();
let response = response_from_tool_result(Ok(result));
let request = write_request("/tmp/request.txt");
let fields = tool_call_update_fields_from_response(&response, Some(&request));
assert_eq!(fields.status, Some(ToolCallStatus::Failed));
assert_eq!(first_tool_call_text(&fields), Some("write failed"));
assert!(fields.locations.is_none());
}
#[test]
fn includes_transport_error_content() {
let response = response_from_tool_result(Err(rmcp::model::ErrorData::new(
rmcp::model::ErrorCode::INTERNAL_ERROR,
"transport failed",
None,
)));
let fields = tool_call_update_fields_from_response(&response, None);
assert_eq!(fields.status, Some(ToolCallStatus::Failed));
assert_eq!(first_tool_call_text(&fields), Some("transport failed"));
}
}
}
@@ -0,0 +1,513 @@
use crate::acp::tool_call_notifier::ToolCallNotifier;
use crate::agents::Agent;
use crate::conversation::message::{
Message, MessageContent, TOOL_META_CHAIN_SUMMARY_KEY, TOOL_META_TITLE_KEY,
};
use crate::model_config::get_fast_model;
use crate::session::SessionManager;
use crate::session_context::with_session_id;
use crate::utils::safe_truncate;
use agent_client_protocol::schema::v1::{
Meta, SessionId, ToolCallId, ToolCallUpdate, ToolCallUpdateFields,
};
use rmcp::model::CallToolRequestParams;
use serde_json::{json, to_string, Map, Number, Value};
use std::slice::from_ref;
use std::sync::Arc;
use std::time::Duration;
use tokio::{spawn, time::sleep};
use tracing::warn;
/// Add `goose.toolChainSummary = { summary, count }` to a `Meta` blob,
/// preserving any existing `goose.*` keys such as `goose.toolCall`.
pub(crate) fn with_tool_chain_summary_meta(
base: Option<Meta>,
summary: &str,
count: usize,
) -> Option<Meta> {
let mut meta = base.unwrap_or_default();
let goose_entry = meta
.entry("goose".to_string())
.or_insert_with(|| Value::Object(Map::new()));
let goose_obj = match goose_entry {
Value::Object(obj) => obj,
other => {
*other = Value::Object(Map::new());
match other {
Value::Object(obj) => obj,
_ => unreachable!(),
}
}
};
let mut chain = Map::new();
chain.insert("summary".to_string(), Value::String(summary.to_string()));
chain.insert("count".to_string(), Value::Number(Number::from(count)));
goose_obj.insert("toolChainSummary".to_string(), Value::Object(chain));
Some(meta)
}
pub(crate) struct ToolTitleEnrichmentContext {
agent: Arc<Agent>,
session_id: SessionId,
tool_call_notifier: ToolCallNotifier,
session_manager: Arc<SessionManager>,
session_id_for_persist: String,
message_id_for_persist: Option<String>,
}
impl ToolTitleEnrichmentContext {
pub(crate) fn new(
agent: &Arc<Agent>,
session_id: &SessionId,
tool_call_notifier: &ToolCallNotifier,
session_manager: &Arc<SessionManager>,
session_id_for_persist: &str,
message_id_for_persist: Option<&str>,
) -> Self {
Self {
agent: agent.clone(),
session_id: session_id.clone(),
tool_call_notifier: tool_call_notifier.clone(),
session_manager: session_manager.clone(),
session_id_for_persist: session_id_for_persist.to_string(),
message_id_for_persist: message_id_for_persist.map(str::to_string),
}
}
pub(crate) fn spawn_title_enrichment(
self,
request_id: String,
tool_call: &CallToolRequestParams,
identity_meta: Option<Meta>,
fallback_title: String,
) {
let args_json = tool_call
.arguments
.as_ref()
.map(|a| {
let s = to_string(a).unwrap_or_default();
if s.len() > 300 {
format!("{}", safe_truncate(&s, 300))
} else {
s
}
})
.unwrap_or_default();
let Self {
agent,
session_id,
tool_call_notifier,
session_manager,
session_id_for_persist,
message_id_for_persist,
} = self;
ToolTitleEnrichmentJob {
agent,
sid: session_id,
request_id,
tool_call_notifier,
name: tool_call.name.to_string(),
identity_meta,
fallback_title,
session_id_for_persist,
message_id_for_persist,
session_manager,
args_json,
}
.spawn();
}
}
struct ToolTitleEnrichmentJob {
agent: Arc<Agent>,
sid: SessionId,
request_id: String,
tool_call_notifier: ToolCallNotifier,
name: String,
identity_meta: Option<Meta>,
fallback_title: String,
session_id_for_persist: String,
message_id_for_persist: Option<String>,
session_manager: Arc<SessionManager>,
args_json: String,
}
impl ToolTitleEnrichmentJob {
fn spawn(self) {
spawn(async move {
let Self {
agent,
sid,
request_id,
tool_call_notifier,
name,
identity_meta,
fallback_title,
session_id_for_persist,
message_id_for_persist,
session_manager,
args_json,
} = self;
let (title, from_llm) = match agent.provider().await {
Ok(provider) => {
if provider.manages_own_context() {
return;
}
let system =
"Summarize this tool call in a short lowercase phrase (3-8 words). \
No punctuation. No quotes. Examples: reading project configuration, \
checking network connectivity, listing files in src directory";
let user_text = format!("Tool: {name}\nArguments: {args_json}");
let message = Message::user().with_text(&user_text);
let model_config = match agent.model_config_for_session(&sid.0).await {
Ok(config) => config,
Err(_) => return,
};
let fast_model_config =
match get_fast_model(provider.get_name(), &model_config).await {
Ok(config) => config,
Err(_) => return,
};
// The fast model occasionally returns an empty response
// under load (rate limiting, transient network). One
// retry with a short backoff is enough to recover the
// common cases without paying for the regular model.
let mut llm_outcome: Option<String> = None;
for attempt in 0..2 {
match with_session_id(
Some(sid.0.to_string()),
provider.complete(&fast_model_config, system, from_ref(&message), &[]),
)
.await
{
Ok((response, _)) => {
let summary: String = response
.content
.iter()
.filter_map(|c: &MessageContent| c.as_text())
.collect::<String>()
.trim()
.to_string();
if !summary.is_empty() {
llm_outcome = Some(summary);
break;
}
if attempt == 0 {
warn!(
"tool call summary: fast_complete returned empty for {request_id} ({name}), retrying once",
);
sleep(Duration::from_millis(150)).await;
}
}
Err(e) => {
if attempt == 0 {
warn!(
"tool call summary: fast_complete errored for {request_id} ({name}): {e}, retrying once",
);
sleep(Duration::from_millis(150)).await;
} else {
warn!(
"tool call summary: fast_complete errored for {request_id} ({name}) after retry: {e}",
);
}
}
}
}
match llm_outcome {
Some(summary) => (summary, true),
None => {
warn!(
"tool call summary: falling back to deterministic title for {request_id} ({name}) — replay will not show an LLM summary for this call",
);
(fallback_title.clone(), false)
}
}
}
Err(e) => {
warn!("tool call summary: failed to get provider: {e}");
(fallback_title.clone(), false)
}
};
let fields = ToolCallUpdateFields::new().title(title.clone());
let _ = tool_call_notifier.send_update(
ToolCallUpdate::new(ToolCallId::new(request_id.clone()), fields)
.meta(identity_meta),
);
// Best-effort persistence: only persist the LLM-generated title
// (not the deterministic fallback) so reload uses fallback_title
// for older or failed cases just like today.
if from_llm {
if let Some(msg_id) = message_id_for_persist {
let patch = json!({
(TOOL_META_TITLE_KEY): title,
});
if let Err(e) = session_manager
.update_tool_request_meta(
&session_id_for_persist,
&msg_id,
&request_id,
patch,
)
.await
{
warn!(
"tool call summary: persist failed for {request_id} in {msg_id}: {e}",
);
}
} else {
warn!(
"tool call summary: missing message_id for {request_id} — title will not survive reload",
);
}
}
});
}
}
pub(crate) struct ChainSummaryEnrichmentContext {
agent: Arc<Agent>,
session_id: SessionId,
tool_call_notifier: ToolCallNotifier,
session_manager: Arc<SessionManager>,
}
impl ChainSummaryEnrichmentContext {
pub(crate) fn new(
agent: &Arc<Agent>,
session_id: &SessionId,
tool_call_notifier: &ToolCallNotifier,
session_manager: &Arc<SessionManager>,
) -> Self {
Self {
agent: agent.clone(),
session_id: session_id.clone(),
tool_call_notifier: tool_call_notifier.clone(),
session_manager: session_manager.clone(),
}
}
pub(crate) fn spawn_chain_summary(
self,
first_tool_call_id: String,
message_id_for_persist: String,
steps: Vec<(String, String)>,
identity_meta: Option<Meta>,
chain_count: usize,
) {
let Self {
agent,
session_id,
tool_call_notifier,
session_manager,
} = self;
ChainSummaryEnrichmentJob {
agent,
sid: session_id,
first_tool_call_id,
message_id_for_persist,
steps,
identity_meta,
chain_count,
tool_call_notifier,
session_manager,
}
.spawn();
}
}
struct ChainSummaryEnrichmentJob {
agent: Arc<Agent>,
sid: SessionId,
first_tool_call_id: String,
message_id_for_persist: String,
steps: Vec<(String, String)>,
identity_meta: Option<Meta>,
chain_count: usize,
tool_call_notifier: ToolCallNotifier,
session_manager: Arc<SessionManager>,
}
impl ChainSummaryEnrichmentJob {
fn spawn(self) {
spawn(async move {
let Self {
agent,
sid,
first_tool_call_id,
message_id_for_persist,
steps,
identity_meta,
chain_count,
tool_call_notifier,
session_manager,
} = self;
let provider = match agent.provider().await {
Ok(provider) => provider,
Err(error) => {
warn!(
"tool chain summary: failed to get provider for chain anchored at {first_tool_call_id}: {error}",
);
return;
}
};
if provider.manages_own_context() {
warn!(
"tool chain summary: provider manages own context; skipping chain anchored at {first_tool_call_id}",
);
return;
}
let system = "Summarize this sequence of tool calls in a short lowercase phrase \
(3-8 words). No punctuation. No quotes. \
Examples: applied dark mode polish, scanned for security issues, \
refactored config loading";
let mut user_text = String::from("Tool call sequence:\n");
for (index, (name, args)) in steps.iter().enumerate() {
user_text.push_str(&format!("Step {}: {} {}\n", index + 1, name, args));
}
let message = Message::user().with_text(&user_text);
let model_config = match agent.model_config_for_session(&sid.0).await {
Ok(config) => config,
Err(_) => return,
};
let fast_model_config = match get_fast_model(provider.get_name(), &model_config).await {
Ok(config) => config,
Err(_) => return,
};
// Match the per-tool retry policy: one retry on empty/error keeps
// the chain header reliable when the fast model is rate-limited or
// momentarily flaky, without escalating to the regular model.
let mut summary: Option<String> = None;
for attempt in 0..2 {
match with_session_id(
Some(sid.0.to_string()),
provider.complete(&fast_model_config, system, from_ref(&message), &[]),
)
.await
{
Ok((response, _)) => {
let generated_summary = response
.content
.iter()
.filter_map(|content: &MessageContent| content.as_text())
.collect::<String>()
.trim()
.to_string();
if !generated_summary.is_empty() {
summary = Some(generated_summary);
break;
}
if attempt == 0 {
warn!(
"tool chain summary: fast_complete returned empty for chain anchored at {first_tool_call_id} ({} steps), retrying once",
steps.len(),
);
sleep(Duration::from_millis(150)).await;
}
}
Err(error) => {
if attempt == 0 {
warn!(
"tool chain summary: fast_complete errored for chain anchored at {first_tool_call_id}: {error}, retrying once",
);
sleep(Duration::from_millis(150)).await;
} else {
warn!(
"tool chain summary: fast_complete errored for chain anchored at {first_tool_call_id} after retry: {error}",
);
}
}
}
}
let Some(summary) = summary else {
warn!(
"tool chain summary: no LLM summary produced for chain anchored at {first_tool_call_id} — replay will fall back to the deterministic phrase",
);
return;
};
let patch = json!({
(TOOL_META_CHAIN_SUMMARY_KEY): {
"summary": &summary,
"count": chain_count,
},
});
if let Err(error) = session_manager
.update_tool_request_meta(
&sid.0,
&message_id_for_persist,
&first_tool_call_id,
patch,
)
.await
{
warn!(
"tool chain summary: persist failed for chain anchored at {first_tool_call_id} in {message_id_for_persist}: {error}",
);
}
let meta = with_tool_chain_summary_meta(identity_meta, &summary, chain_count);
let fields = ToolCallUpdateFields::new();
let _ = tool_call_notifier.send_update(
ToolCallUpdate::new(ToolCallId::new(first_tool_call_id), fields).meta(meta),
);
});
}
}
#[cfg(test)]
mod tests {
mod with_tool_chain_summary_meta {
use super::super::with_tool_chain_summary_meta;
use crate::acp::server::tool_calls::conversion::tool_call_identity_meta;
use crate::conversation::message::ToolRequest;
use rmcp::model::CallToolRequestParams;
use serde_json::json;
#[test]
fn creates_fresh_when_none() {
let meta = with_tool_chain_summary_meta(None, "applied dark mode", 4)
.expect("meta should be created");
assert_eq!(
meta.get("goose"),
Some(&json!({
"toolChainSummary": { "summary": "applied dark mode", "count": 4 },
})),
);
}
#[test]
fn preserves_existing_tool_call_identity() {
let existing = tool_call_identity_meta(&ToolRequest {
id: "req_1".to_string(),
tool_call: Ok(CallToolRequestParams::new("developer__shell")),
metadata: None,
tool_meta: None,
});
let meta = with_tool_chain_summary_meta(existing, "ran two commands", 2)
.expect("meta should be created");
let goose = meta.get("goose").expect("goose key");
assert_eq!(
goose.get("toolCall"),
Some(&json!({
"toolName": "developer__shell",
"extensionName": "developer",
})),
);
assert_eq!(
goose.get("toolChainSummary"),
Some(&json!({ "summary": "ran two commands", "count": 2 })),
);
}
}
}
@@ -0,0 +1,3 @@
pub(super) mod chain;
pub(super) mod conversion;
pub(super) mod enrichment;
@@ -1,5 +1,5 @@
use agent_client_protocol::schema::v1::{
Meta, SessionUpdate, ToolCallId, ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields,
Meta, ToolCallId, ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields,
};
use rmcp::model::{LoggingMessageNotificationParam, ProgressNotificationParam, ServerNotification};
use serde::Serialize;
@@ -21,7 +21,7 @@ enum ToolNotification {
pub(super) fn tool_notification_update(
tool_call_id: impl Into<ToolCallId>,
notification: ServerNotification,
) -> Option<SessionUpdate> {
) -> Option<ToolCallUpdate> {
let tool_notification = match notification {
ServerNotification::LoggingMessageNotification(notification) => ToolNotification::Message {
params: notification.params,
@@ -45,18 +45,19 @@ pub(super) fn tool_notification_update(
serde_json::to_value(tool_notification).ok()?,
);
Some(SessionUpdate::ToolCallUpdate(
Some(
ToolCallUpdate::new(
tool_call_id,
ToolCallUpdateFields::new().status(ToolCallStatus::InProgress),
)
.meta(meta),
))
)
}
#[cfg(test)]
mod tests {
use super::tool_notification_update;
use agent_client_protocol::schema::v1::SessionUpdate;
use rmcp::model::{
CancelledNotificationParam, CustomNotification, LoggingLevel,
LoggingMessageNotificationParam, Notification, NumberOrString, ProgressNotificationParam,
@@ -82,7 +83,8 @@ mod tests {
));
let update = tool_notification_update("tool_1", notification).expect("expected update");
let value = serde_json::to_value(update).expect("update should serialize");
let value = serde_json::to_value(SessionUpdate::ToolCallUpdate(update))
.expect("update should serialize");
assert_eq!(value["sessionUpdate"], "tool_call_update");
assert_eq!(value["toolCallId"], "tool_1");
@@ -114,7 +116,8 @@ mod tests {
));
let update = tool_notification_update("tool_1", notification).expect("expected update");
let value = serde_json::to_value(update).expect("update should serialize");
let value = serde_json::to_value(SessionUpdate::ToolCallUpdate(update))
.expect("update should serialize");
assert_eq!(value["sessionUpdate"], "tool_call_update");
assert_eq!(value["toolCallId"], "tool_1");
@@ -159,7 +162,8 @@ mod tests {
));
let update = tool_notification_update("tool_1", notification).expect("expected update");
let value = serde_json::to_value(update).expect("update should serialize");
let value = serde_json::to_value(SessionUpdate::ToolCallUpdate(update))
.expect("update should serialize");
assert_eq!(value["sessionUpdate"], "tool_call_update");
assert_eq!(value["toolCallId"], "tool_1");
@@ -0,0 +1,39 @@
use agent_client_protocol::schema::v1::{
SessionId, SessionNotification, SessionUpdate, ToolCall, ToolCallUpdate,
};
use agent_client_protocol::{Client, ConnectionTo};
#[derive(Clone)]
pub(crate) struct ToolCallNotifier {
connection: ConnectionTo<Client>,
session_id: SessionId,
}
impl ToolCallNotifier {
pub(crate) fn new(connection: &ConnectionTo<Client>, session_id: &SessionId) -> Self {
Self {
connection: connection.clone(),
session_id: session_id.clone(),
}
}
pub(crate) fn send_initial(
&self,
tool_call: ToolCall,
) -> Result<(), agent_client_protocol::Error> {
self.connection.send_notification(SessionNotification::new(
self.session_id.clone(),
SessionUpdate::ToolCall(tool_call),
))
}
pub(crate) fn send_update(
&self,
update: ToolCallUpdate,
) -> Result<(), agent_client_protocol::Error> {
self.connection.send_notification(SessionNotification::new(
self.session_id.clone(),
SessionUpdate::ToolCallUpdate(update),
))
}
}
+15 -1
View File
@@ -415,8 +415,22 @@ pub async fn run_fs_write_text_file_true<C: Connection>() {
.await
.unwrap();
assert!(!output.text.is_empty());
let updates = session.session_updates();
let initial_tool_call_id = updates
.iter()
.find_map(|update| match update {
SessionUpdate::ToolCall(tool_call) => Some(&tool_call.tool_call_id),
_ => None,
})
.expect("expected an initial tool call");
for update in &updates {
if let SessionUpdate::ToolCallUpdate(update) = update {
assert_eq!(&update.tool_call_id, initial_tool_call_id);
}
}
assert_notifications(
&session.notifications(),
&fixtures::to_notifications(&updates),
&[
Notification::ToolCall,
Notification::ToolCallKind(ToolKind::Edit),