Skip to content
Merged
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
24 changes: 24 additions & 0 deletions src/apps/cli/src/peer_host/commands/dialog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,30 @@ pub(crate) async fn cancel_dialog_turn(
Ok(json!({ "success": true }))
}

/// Cancel a single running tool execution on this host.
///
/// The controller renders Terminal cards for Turns this host owns, including
/// the Interrupt button. Without this handler the `cancel_tool` HostInvoke
/// command fell into the unsupported dispatch branch: the controller restored
/// the button and logged an error while the target command kept running here.
/// This reaches the Core-owned coordinator via the same compatibility surface
/// the Desktop `cancel_tool` Tauri command uses — one level finer than
/// `cancel_dialog_turn`.
pub(crate) async fn cancel_tool(
state: &PeerHostState,
args: &Value,
) -> Result<Value, String> {
let request = request_value(args);
let tool_use_id = get_string(request, "toolUseId")?;
let reason = optional_string(request, "reason")
.unwrap_or_else(|| "User cancelled".to_string());
state
.compatibility
.cancel_tool(&tool_use_id, reason)
.await?;
Ok(json!({ "success": true }))
}

#[cfg(test)]
mod tests {
use serde_json::json;
Expand Down
15 changes: 15 additions & 0 deletions src/apps/cli/src/peer_host/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ mod session;
mod snapshot;
mod soft;
mod system;
mod tools;
mod workspace;

use serde_json::Value;
Expand Down Expand Up @@ -69,6 +70,14 @@ pub(crate) async fn dispatch(
"check_path_exists" => filesystem::check_path_exists(args).await,
"create_directory" => filesystem::create_directory(state, args).await,

// Tools catalog — read-only tool listing for Agents / Assistant
// Defaults UI. CLI Host assembles the same Core tool registry as
// Desktop and returns the identical DTO shape, so a controller cannot
// tell "CLI Host doesn't support catalog query" from "the runtime
// really has no tools". Without this the call fell into the unsupported
// dispatch branch and the UI silently rendered an empty tool list.
"get_all_tools_info" => tools::get_all_tools_info().await,

// Sessions
"list_persisted_sessions" => session::list_persisted_sessions(state, args).await,
"list_persisted_sessions_page" => session::list_persisted_sessions_page(state, args).await,
Expand Down Expand Up @@ -101,6 +110,12 @@ pub(crate) async fn dispatch(
// Dialog / tools
"start_dialog_turn" => dialog::start_dialog_turn(state, args).await,
"cancel_dialog_turn" => dialog::cancel_dialog_turn(state, args).await,
// Per-tool interrupt. The controller renders Terminal cards for Turns
// this host owns, so it must be able to stop a running tool here —
// same owner as cancel_dialog_turn, one level finer. Reaches the Core
// coordinator via the compatibility surface both CLI and Desktop Peer
// Hosts share.
"cancel_tool" => dialog::cancel_tool(state, args).await,
"list_pending_permission_requests" => permission::list_pending_permission_requests(state),
"subscribe_permission_requests" => permission::subscribe_permission_requests(),
"respond_permission" => permission::respond_permission(state, args).await,
Expand Down
17 changes: 17 additions & 0 deletions src/apps/cli/src/peer_host/commands/tools.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//! Tools HostInvoke handlers for CLI Peer Host.

use serde_json::Value;

use bitfun_core::agentic::tools::product_runtime::build_all_tools_info;

/// Read-only tool catalog for the Agents / Assistant Defaults UI.
///
/// CLI Host assembles the same Core tool registry as Desktop; this returns the
/// identical DTO shape so a controller cannot tell "CLI Host doesn't support
/// catalog query" from "the runtime really has no tools". Without this, the
/// controller's `get_all_tools_info` call would fall into the unsupported
/// dispatch branch and the UI would silently render an empty tool list.
pub(crate) async fn get_all_tools_info() -> Result<Value, String> {
let tools = build_all_tools_info().await;
serde_json::to_value(tools).map_err(|error| format!("Failed to serialize tool info: {error}"))
}
14 changes: 14 additions & 0 deletions src/apps/cli/src/peer_host/control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,24 @@ pub(crate) fn peer_mode_ping_value() -> Value {
"ok": true,
"peer": true,
"device_id": device_id,
// Declares which kind of host answered so the controller can resolve
// capabilities that an older CLI did not advertise. An older CLI
// (pre-`50b76516`) omits `cancel_tool`/`tool_catalog` and never
// implemented them; reporting `host_type: "cli"` lets the controller
// gate the Terminal Interrupt button / tool list off instead of showing
// an action that silently fails. See PR #2428 round 5 #1.
"host_type": "cli",
"capabilities": {
"idempotent_dialog_submit": true,
"targeted_session_rollback": true,
"token_usage_statistics": true,
// Per-tool interrupt and read-only tool catalog are implemented on
// this host (see commands::dialog::cancel_tool and
// commands::tools::get_all_tools_info). Advertising them lets the
// controller gate the Terminal Interrupt button and the tool
// catalog UI on a real capability instead of guessing.
"cancel_tool": true,
"tool_catalog": true,
},
})
}
Expand Down
44 changes: 44 additions & 0 deletions src/apps/cli/src/peer_host/deny.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,50 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[
// belongs to the person at this machine, so refuse it explicitly rather
// than relying on the command being unimplemented here.
"git_trust_repository",
// Controller app-shell state mirrored from the FE deny list. An older or
// non-Web-UI controller can still HostInvoke these onto this peer, so the
// CLI peer host must refuse them independently of the FE optimization.
// Keep in sync with `src/web-ui/.../adapters/peer-device-adapter.ts`
// LOCAL_ONLY_COMMANDS and `src/apps/desktop/src/api/peer_host_invoke.rs`.
// These controller-owned commands are not implemented here either, but
// being unimplemented is not the boundary — refuse explicitly.
"i18n_get_current_language",
"i18n_set_language",
"i18n_get_supported_languages",
"i18n_get_config",
"i18n_set_config",
"get_pending_announcements",
"get_announcement_tips",
"mark_announcement_seen",
"dismiss_announcement",
"never_show_announcement",
"trigger_announcement",
"list_agent_companion_pets",
"import_agent_companion_pet_package",
"delete_agent_companion_pet_package",
"generate_insights",
"get_latest_insights",
"load_insights_report",
"has_insights_data",
"cancel_insights_generation",
"report_ide_control_result",
"browser_control_launch",
"browser_control_list_browsers",
"browser_control_get_status",
"browser_control_restart_with_cdp",
"browser_control_enable_default_cdp",
"browser_webview_create",
"browser_webview_eval",
"browser_webview_navigate",
"browser_webview_reload",
"browser_webview_set_bounds",
"computer_use_get_status",
"debug_devtools_available",
"debug_open_devtools",
"resize_agent_companion_desktop_pet",
"show_agent_companion_desktop_pet",
"hide_agent_companion_desktop_pet",
"append_flow_chat_diagnostics",
];

/// Desktop IDE surfaces that CLI Peer Host does not implement.
Expand Down
51 changes: 51 additions & 0 deletions src/apps/cli/src/peer_host/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,33 @@ mod tests {
value.pointer("/capabilities/token_usage_statistics"),
Some(&json!(true))
);
assert_eq!(
value.pointer("/capabilities/cancel_tool"),
Some(&json!(true))
);
assert_eq!(
value.pointer("/capabilities/tool_catalog"),
Some(&json!(true))
);
}
other => panic!("unexpected response: {other:?}"),
}
}

#[tokio::test]
async fn peer_mode_ping_advertises_cli_host_type() {
// An older CLI did not advertise `cancel_tool`/`tool_catalog`; the
// `host_type: "cli"` field lets the controller resolve those missing
// capabilities as unsupported instead of optimistically invoking a
// command the CLI never implemented. See PR #2428 round 5 #1.
let resp = handle_host_invoke("peer_mode_ping", json!({})).await;
match resp {
RemoteResponse::HostInvokeResult {
ok: true,
value: Some(value),
error: None,
} => {
assert_eq!(value.get("host_type").and_then(|v| v.as_str()), Some("cli"));
}
other => panic!("unexpected response: {other:?}"),
}
Expand Down Expand Up @@ -188,6 +215,30 @@ mod tests {
assert_eq!(dispatch_target_verb("dispatch_target_unknown"), None);
}

/// `cancel_tool` and `get_all_tools_info` were previously unimplemented on
/// the CLI peer host, so a controller rendering a CLI Peer session saw an
/// ineffective Interrupt button and an empty tool list. They are now
/// implemented in `commands::dialog::cancel_tool` and
/// `commands::tools::get_all_tools_info`; this test pins that neither is
/// refused by the local-only or CLI-unsupported gate before reaching the
/// implemented handler. A future regression that removes the handler but
/// leaves the command routable would land in the unsupported fallthrough
/// branch, not here — that is caught by the capability advertisement +
/// frontend gate instead.
#[test]
fn cancel_tool_and_tool_catalog_are_not_refused_before_dispatch() {
for command in ["cancel_tool", "get_all_tools_info"] {
assert!(
!is_local_only_command(command),
"{command} must be routable to the peer host"
);
assert!(
!is_cli_unsupported_command(command),
"{command} must reach its implemented handler, not the unsupported gate"
);
}
}

#[tokio::test]
async fn attach_detach_updates_subscribers() {
let _ = handle_host_invoke(
Expand Down
94 changes: 91 additions & 3 deletions src/apps/desktop/src/api/peer_host_invoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,10 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[
"remote_connect_weixin_qr_poll",
"remote_connect_get_bot_verbose_mode",
"remote_connect_set_bot_verbose_mode",
// This-machine computer-use / OS permission prompts
"computer_use_request_permissions",
"computer_use_open_system_settings",
// Computer-use OS permission prompts + system-settings are intentionally NOT
// local-only: under Desktop Peer Mode they must run on the peer host B (B
// surfaces B's own OS permission prompts / settings), reached via
// bridge_via_webview. CLI Peer refuses them in deny.rs. See SessionConfig.
// Detached dispatch uses controller-owned SSH credentials and observers.
"dispatch_list_targets",
"dispatch_probe_target",
Expand Down Expand Up @@ -145,6 +146,62 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[
// That decision stays with the person at that machine; a controller can
// still read `git_get_repository_trust` and relay the manual command.
"git_trust_repository",
// Controller app-shell state mirrored from the FE deny list. An older or
// non-Web-UI controller can still HostInvoke these onto this peer, so the
// peer host must refuse them independently of the FE optimization. Keep in
// sync with `src/web-ui/.../adapters/peer-device-adapter.ts`
// LOCAL_ONLY_COMMANDS and `src/apps/cli/src/peer_host/deny.rs`.
// UI locale writes the controller's config and rebuilds THIS machine's
// macOS menubar/tray; routing it to a peer writes the wrong config.
"i18n_get_current_language",
"i18n_set_language",
"i18n_get_supported_languages",
"i18n_get_config",
"i18n_set_config",
// Announcement scheduler/state: get_pending / get_tips run the scheduler
// (mutate app_open_count + persist); seen / dismiss / never-show write
// controller announcement state. Refused on the peer.
"get_pending_announcements",
"get_announcement_tips",
"mark_announcement_seen",
"dismiss_announcement",
"never_show_announcement",
"trigger_announcement",
// Companion pets live on the controller's desktop; the import zip path is
// picked by a local dialog on the controller and is not readable here.
"list_agent_companion_pets",
"import_agent_companion_pet_package",
"delete_agent_companion_pet_package",
// Insights is the controller's own usage report: it reads the controller's
// session history and writes the HTML to the controller's user_data_dir.
"generate_insights",
"get_latest_insights",
"load_insights_report",
"has_insights_data",
"cancel_insights_generation",
// IDE control events drive the controller window's panels; the result
// report must settle on the controller's transport, not here.
"report_ide_control_result",
// Controller app-shell / local-device commands (embedded webview/DevTools/
// desktop-pet/diagnostics) operate on the controller's OWN surfaces and a
// peer host has no implementation for them, so they stay local-only.
//
// NOTE: the runtime-owning Browser Control and Computer Use commands are
// NOT local-only — they run the agent Tool, so under Desktop Peer Mode they
// route to the peer host B via bridge_via_webview (reads B's own browser
// and OS). CLI Peer refuses them in deny.rs and the UI gates the section on
// host type. See SessionConfig + cli deny.rs.
"browser_webview_create",
"browser_webview_eval",
"browser_webview_navigate",
"browser_webview_reload",
"browser_webview_set_bounds",
"debug_devtools_available",
"debug_open_devtools",
"resize_agent_companion_desktop_pet",
"show_agent_companion_desktop_pet",
"hide_agent_companion_desktop_pet",
"append_flow_chat_diagnostics",
];

static PENDING: OnceLock<Mutex<HashMap<String, oneshot::Sender<HostInvokeBridgeResult>>>> =
Expand Down Expand Up @@ -364,10 +421,25 @@ pub async fn peer_mode_ping() -> Result<Value, String> {
"peer": true,
"device_id": current_device_id_for_peer()
.unwrap_or_else(|_| "unknown".to_string()),
// Declares which kind of host answered so the controller can resolve
// capabilities that an older host did not advertise. An older Desktop
// (pre-`50b76516`) omits `cancel_tool`/`tool_catalog` but still reports
// `host_type: "desktop"` — and Desktop has always implemented both — so
// the controller keeps the Interrupt button / tool list. An older CLI
// reports `host_type: "cli"` and never implemented them, so the
// controller gates them off instead of showing an action that silently
// fails. See PR #2428 round 5 #1.
"host_type": "desktop",
"capabilities": {
"idempotent_dialog_submit": true,
"targeted_session_rollback": true,
"token_usage_statistics": true,
// Desktop implements both per-tool cancel and the tool catalog
// (agentic_api::cancel_tool, tool_api::get_all_tools_info), so the
// controller can gate the Terminal Interrupt button and the tool
// catalog UI on these the same way it does on the CLI peer host.
"cancel_tool": true,
"tool_catalog": true,
},
}))
}
Expand Down Expand Up @@ -469,6 +541,10 @@ mod tests {
#[tokio::test]
async fn peer_ping_advertises_mutation_capabilities() {
let value = peer_mode_ping().await.expect("peer ping");
assert_eq!(
value.get("host_type").and_then(Value::as_str),
Some("desktop")
);
assert_eq!(
value
.pointer("/capabilities/idempotent_dialog_submit")
Expand All @@ -487,6 +563,18 @@ mod tests {
.and_then(Value::as_bool),
Some(true)
);
assert_eq!(
value
.pointer("/capabilities/cancel_tool")
.and_then(Value::as_bool),
Some(true)
);
assert_eq!(
value
.pointer("/capabilities/tool_catalog")
.and_then(Value::as_bool),
Some(true)
);
}

#[test]
Expand Down
Loading