Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src-tauri/src/acp/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5518,6 +5518,9 @@ fn cursor_companion_title_from_content(content: Option<&str>) -> Option<&'static
if text.starts_with("Delegation successful. task_id=") {
return Some(crate::acp::delegation::DELEGATE_TOOL_REWRITE_TITLE);
}
if text.starts_with("Continue successful. task_id=") {
return Some(crate::acp::delegation::CONTINUE_TOOL_REWRITE_TITLE);
}
// Cheap guards before the full JSON parse: the status report is a JSON
// object whose first key is `tasks`.
if !text.starts_with('{') || !text.contains("\"tasks\"") {
Expand Down
807 changes: 791 additions & 16 deletions src-tauri/src/acp/delegation/broker.rs

Large diffs are not rendered by default.

100 changes: 83 additions & 17 deletions src-tauri/src/acp/delegation/companion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,18 @@
//! The companion speaks newline-delimited JSON-RPC 2.0 on stdio:
//! one request → one response per line, with concurrent dispatch so
//! `notifications/cancelled` can race an in-flight `tools/call`. It exposes up
//! to six tools — `delegate_to_agent` (async; returns a `task_id` ack),
//! up to eight tools — `delegate_to_agent` (async; returns a `task_id` ack),
//! `get_delegation_status` (poll/long-poll for the result), `cancel_delegation`,
//! `check_user_feedback` (pull the user's mid-turn steering notes),
//! `ask_user_question` (block on a multiple-choice card), and `get_session_info`
//! (resolve a referenced session by id) — whose schemas are embedded at compile
//! time from [`TOOL_SCHEMA_JSON`] and gated by the `--features` groups (delegation
//! / feedback / ask / sessions). Only `delegate_to_agent` registers a broker-side
//! cancel handle; canceling a status / cancel / feedback / session round-trip
//! merely suppresses its response — and for `check_user_feedback` also skips the
//! delivery commit, so a cancelled note stays pending.
//! `continue_with_session` (follow-up in the same child session), `close_session`
//! (permanently retire a child), `check_user_feedback` (pull the user's mid-turn
//! steering notes), `ask_user_question` (block on a multiple-choice card), and
//! `get_session_info` (resolve a referenced session by id) — whose schemas are
//! embedded at compile time from [`TOOL_SCHEMA_JSON`] and gated by the
//! `--features` groups (delegation / feedback / ask / sessions). Only
//! `delegate_to_agent` registers a broker-side cancel handle; canceling a status
//! / cancel / continue / close / feedback / session round-trip merely suppresses
//! its response — and for `check_user_feedback` also skips the delivery commit,
//! so a cancelled note stays pending.
//!
//! Notifications (id = None) produce no response, matching MCP's expectation
//! that `notifications/initialized` etc. are fire-and-forget.
Expand Down Expand Up @@ -42,11 +44,13 @@ use serde_json::{json, Value};
use tokio::sync::{oneshot, Mutex};

use crate::acp::delegation::transport::{
client_ask_round_trip, client_cancel, client_cancel_task_round_trip, client_commit_feedback,
client_ask_round_trip, client_cancel, client_cancel_task_round_trip,
client_close_session_round_trip, client_commit_feedback, client_continue_round_trip,
client_feedback_round_trip, client_round_trip, client_session_round_trip,
client_status_round_trip, BrokerAskRequest, BrokerCancelRequest, BrokerCancelTaskRequest,
BrokerCommitFeedbackRequest, BrokerFeedbackRequest, BrokerRequest, BrokerResponse,
BrokerSessionRequest, BrokerStatusRequest,
BrokerCloseSessionRequest, BrokerCommitFeedbackRequest, BrokerContinueRequest,
BrokerFeedbackRequest, BrokerRequest, BrokerResponse, BrokerSessionRequest,
BrokerStatusRequest,
};
use crate::acp::question::parse_questions;
use crate::acp::session_info::MAX_SESSION_MESSAGES;
Expand Down Expand Up @@ -179,7 +183,11 @@ impl CompanionFeatures {
"check_user_feedback" => self.feedback,
"ask_user_question" => self.ask,
"get_session_info" => self.sessions,
"delegate_to_agent" | "get_delegation_status" | "cancel_delegation" => self.delegation,
"delegate_to_agent"
| "get_delegation_status"
| "cancel_delegation"
| "continue_with_session"
| "close_session" => self.delegation,
_ => false,
}
}
Expand Down Expand Up @@ -479,6 +487,62 @@ async fn build_tools_call_spawn(
Box::pin(async move { client_cancel_task_round_trip(&socket, &req).await });
register_and_spawn(inflight, id, None, round_trip, render_task_report).await
}
"continue_with_session" => {
let task_id = match arguments.get("task_id").and_then(|v| v.as_str()) {
Some(s) if !s.is_empty() => s.to_string(),
_ => {
return LineAction::Respond(err(
id,
-32602,
"continue_with_session requires a non-empty string task_id",
));
}
};
let message = match arguments.get("message").and_then(|v| v.as_str()) {
Some(s) if !s.trim().is_empty() => s.to_string(),
_ => {
return LineAction::Respond(err(
id,
-32602,
"continue_with_session requires a non-empty string message",
));
}
};
let tool_use_id = params
.get("_meta")
.and_then(|m| m.get("tool_use_id"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let req = BrokerContinueRequest {
token: ctx.token.clone(),
parent_connection_id: ctx.parent_connection_id.clone(),
parent_tool_use_id: tool_use_id,
task_id,
message,
};
let round_trip =
Box::pin(async move { client_continue_round_trip(&socket, &req).await });
register_and_spawn(inflight, id, None, round_trip, render_task_report).await
}
"close_session" => {
let task_id = match arguments.get("task_id").and_then(|v| v.as_str()) {
Some(s) if !s.is_empty() => s.to_string(),
_ => {
return LineAction::Respond(err(
id,
-32602,
"close_session requires a non-empty string task_id",
));
}
};
let req = BrokerCloseSessionRequest {
token: ctx.token.clone(),
task_id,
};
let round_trip =
Box::pin(async move { client_close_session_round_trip(&socket, &req).await });
register_and_spawn(inflight, id, None, round_trip, render_task_report).await
}
"check_user_feedback" => {
let req = BrokerFeedbackRequest {
token: ctx.token.clone(),
Expand Down Expand Up @@ -1202,16 +1266,18 @@ mod tests {
}

#[tokio::test]
async fn tools_list_returns_three_delegation_tools() {
async fn tools_list_returns_five_delegation_tools() {
let line = r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#;
let resp = unwrap_respond(dispatch_for_test(line).await);
let result = resp.result.unwrap();
let tools = result["tools"].as_array().unwrap();
assert_eq!(tools.len(), 3);
assert_eq!(tools.len(), 5);
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
assert!(names.contains(&"delegate_to_agent"));
assert!(names.contains(&"get_delegation_status"));
assert!(names.contains(&"cancel_delegation"));
assert!(names.contains(&"continue_with_session"));
assert!(names.contains(&"close_session"));
// delegate_to_agent schema still enumerates all 12 agent types.
let delegate = tools
.iter()
Expand Down Expand Up @@ -1694,7 +1760,7 @@ mod tests {
dispatch_for_test(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#).await,
);
assert!(!names.contains(&"check_user_feedback".to_string()));
assert_eq!(names.len(), 3);
assert_eq!(names.len(), 5);
}

#[tokio::test]
Expand All @@ -1703,7 +1769,7 @@ mod tests {
dispatch_with_features(BOTH, r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#).await,
);
assert!(names.contains(&"check_user_feedback".to_string()));
assert_eq!(names.len(), 4);
assert_eq!(names.len(), 6);
}

#[tokio::test]
Expand Down
81 changes: 81 additions & 0 deletions src-tauri/src/acp/delegation/listener.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,10 @@ impl DelegationListener {
reports_response(reports)?
}
BrokerMessage::CancelTask(req) => report_response(self.process_cancel_task(req).await)?,
BrokerMessage::Continue(req) => report_response(self.process_continue(req).await)?,
BrokerMessage::CloseSession(req) => {
report_response(self.process_close_session(req).await)?
}
BrokerMessage::Feedback(req) => {
// at-least-once delivery: READ pending notes (no mutation),
// WRITE the response, and COMMIT them delivered ONLY on a
Expand Down Expand Up @@ -419,6 +423,83 @@ impl DelegationListener {
.await
}

/// Validate the token and continue a settled child session. Backs
/// `continue_with_session`.
async fn process_continue(
&self,
req: crate::acp::delegation::transport::BrokerContinueRequest,
) -> DelegationTaskReport {
let Some(entry) = self.tokens.lookup(&req.token).await else {
return unknown_report(&req.task_id);
};
if entry.parent_connection_id != req.parent_connection_id {
return unknown_report(&req.task_id);
}
// Identity restoration for identity-less hosts (Cursor).
let mut rename_input = serde_json::Map::new();
rename_input.insert(
"task_id".into(),
serde_json::Value::String(req.task_id.clone()),
);
rename_input.insert(
"message".into(),
serde_json::Value::String(req.message.clone()),
);
self.broker
.rewrite_identityless_tool_call(
&entry.parent_connection_id,
crate::acp::delegation::CONTINUE_TOOL_REWRITE_TITLE,
serde_json::Value::Object(rename_input),
)
.await;
let parent_conversation_id = self
.parent_lookup
.current_conversation_id(&entry.parent_connection_id)
.await;
self.broker
.continue_delegation(
&entry.parent_connection_id,
parent_conversation_id,
&req.task_id,
req.message,
)
.await
}

/// Validate the token and permanently close a child session. Backs
/// `close_session`.
async fn process_close_session(
&self,
req: crate::acp::delegation::transport::BrokerCloseSessionRequest,
) -> DelegationTaskReport {
let Some(entry) = self.tokens.lookup(&req.token).await else {
return unknown_report(&req.task_id);
};
let mut rename_input = serde_json::Map::new();
rename_input.insert(
"task_id".into(),
serde_json::Value::String(req.task_id.clone()),
);
self.broker
.rewrite_identityless_tool_call(
&entry.parent_connection_id,
crate::acp::delegation::CLOSE_TOOL_REWRITE_TITLE,
serde_json::Value::Object(rename_input),
)
.await;
let parent_conversation_id = self
.parent_lookup
.current_conversation_id(&entry.parent_connection_id)
.await;
self.broker
.close_delegation_session(
&entry.parent_connection_id,
parent_conversation_id,
&req.task_id,
)
.await
}

/// Validate the token and resolve the `check_user_feedback` target: the
/// caller's parent connection id. `None` on an invalid token — the LLM can't
/// usefully distinguish "no notes" from "bad token", and we don't leak which.
Expand Down
12 changes: 8 additions & 4 deletions src-tauri/src/acp/delegation/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@
//! parent LLM ◄── MCP tool_result ◄── DelegationOutcome ◄───┘
//! ```
//!
//! v1 is one-shot (function-call semantics): after the child's first
//! `TurnComplete`, the broker resolves the pending call, sends `disconnect`
//! to the child, and returns. v2 will introduce `continue_with_session` /
//! `close_session` tools without protocol breakage.
//! After each child turn settles, the broker keeps the child process alive
//! (completed / failed) so `continue_with_session` can send follow-ups in the
//! SAME session with full prior context. `close_session` (or cancel / parent
//! teardown) permanently retires the child. Re-spawning with the child's
//! agent `external_id` covers the case where the process was reaped while the
//! conversation row still exists.

pub mod broker;
pub mod companion;
Expand All @@ -55,3 +57,5 @@ pub mod types;
pub const DELEGATE_TOOL_REWRITE_TITLE: &str = "codeg-mcp__delegate_to_agent";
pub const STATUS_TOOL_REWRITE_TITLE: &str = "codeg-mcp__get_delegation_status";
pub const CANCEL_TOOL_REWRITE_TITLE: &str = "codeg-mcp__cancel_delegation";
pub const CONTINUE_TOOL_REWRITE_TITLE: &str = "codeg-mcp__continue_with_session";
pub const CLOSE_TOOL_REWRITE_TITLE: &str = "codeg-mcp__close_session";
Loading