fix(claude-acp): surface tool calls and results to the session bus (#8408)

This commit is contained in:
walid
2026-04-08 23:13:40 -05:00
committed by GitHub
parent f682035bb8
commit e200a896a2
4 changed files with 431 additions and 13 deletions
+86 -2
View File
@@ -356,7 +356,24 @@ impl Agent {
coerce_tool_arguments(tool_call.arguments.clone(), &schema_value);
if let Some(ref meta) = tool.meta {
coerced_req.tool_meta = serde_json::to_value(meta).ok();
// Merge registry meta into existing tool_meta;
// existing keys win so provider markers (e.g.
// goose.external_dispatch) survive coercion.
let new_meta = serde_json::to_value(meta).ok();
coerced_req.tool_meta =
match (coerced_req.tool_meta.take(), new_meta) {
(
Some(Value::Object(mut existing)),
Some(Value::Object(new)),
) => {
for (k, v) in new {
existing.entry(k).or_insert(v);
}
Some(Value::Object(existing))
}
(None, new) => new,
(existing, _) => existing,
};
}
}
}
@@ -388,7 +405,12 @@ impl Agent {
let coerced_req = &tool_requests[tool_request_index];
tool_request_index += 1;
let should_include = if let Ok(tool_call) = &coerced_req.tool_call {
// Always keep externally-dispatched requests visible, even if
// their name happens to overlap a registered frontend tool —
// they're observation-only and must not be removed from history.
let should_include = if coerced_req.is_externally_dispatched() {
true
} else if let Ok(tool_call) = &coerced_req.tool_call {
!self.is_frontend_tool(&tool_call.name).await
} else {
true
@@ -420,6 +442,11 @@ impl Agent {
let mut other_requests = Vec::new();
for request in tool_requests {
// Skip externally-dispatched requests (e.g. claude-acp); the
// provider already executed the tool. Stays in filtered_message.
if request.is_externally_dispatched() {
continue;
}
if let Ok(tool_call) = &request.tool_call {
if self.is_frontend_tool(&tool_call.name).await {
frontend_requests.push(request);
@@ -720,6 +747,63 @@ mod tests {
));
}
#[tokio::test]
async fn categorize_tool_requests_skips_externally_dispatched_and_preserves_marker() {
// External requests must (1) survive coercion with goose.external_dispatch
// intact, (2) be excluded from both dispatch buckets, (3) stay in
// filtered_message.
use crate::conversation::message::TOOL_META_EXTERNAL_DISPATCH_KEY;
let agent = crate::agents::Agent::new();
let registry_tool = Tool::new("test_tool", "a test tool", object!({ "type": "object" }))
.with_meta(rmcp::model::Meta(
serde_json::json!({ "ui": { "visibility": ["model"] } })
.as_object()
.unwrap()
.clone(),
));
let response = Message::assistant().with_tool_request_with_metadata(
"tool-1",
Ok(rmcp::model::CallToolRequestParams::new("test_tool")),
None,
Some(serde_json::json!({ TOOL_META_EXTERNAL_DISPATCH_KEY: true })),
);
let (frontend_requests, other_requests, filtered_message) = agent
.categorize_tool_requests(&response, &[registry_tool], false)
.await;
assert!(
frontend_requests.is_empty(),
"external request leaked into frontend_requests: {frontend_requests:?}"
);
assert!(
other_requests.is_empty(),
"external request leaked into other_requests: {other_requests:?}"
);
assert_eq!(filtered_message.content.len(), 1);
let tool_req = match &filtered_message.content[0] {
MessageContent::ToolRequest(req) => req,
other => panic!("expected ToolRequest, got {other:?}"),
};
assert!(
tool_req.is_externally_dispatched(),
"goose.external_dispatch marker was clobbered by coercion; merged tool_meta = {:?}",
tool_req.tool_meta
);
let merged = tool_req
.tool_meta
.as_ref()
.and_then(|v| v.as_object())
.expect("tool_meta should be an object after merge");
assert!(
merged.contains_key("ui"),
"registry tool meta keys were dropped; merged tool_meta = {merged:?}"
);
}
fn make_tool_with_meta(meta_json: Option<serde_json::Value>) -> Tool {
let mut tool = Tool::new("test_tool", "a test tool", object!({ "type": "object" }));
if let Some(v) = meta_json {