diff --git a/src/apps/cli/src/peer_host/commands/dialog.rs b/src/apps/cli/src/peer_host/commands/dialog.rs index 801dc61f7d..2b963b893d 100644 --- a/src/apps/cli/src/peer_host/commands/dialog.rs +++ b/src/apps/cli/src/peer_host/commands/dialog.rs @@ -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 { + 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; diff --git a/src/apps/cli/src/peer_host/commands/mod.rs b/src/apps/cli/src/peer_host/commands/mod.rs index 3435aeed39..054bb505f5 100644 --- a/src/apps/cli/src/peer_host/commands/mod.rs +++ b/src/apps/cli/src/peer_host/commands/mod.rs @@ -10,6 +10,7 @@ mod session; mod snapshot; mod soft; mod system; +mod tools; mod workspace; use serde_json::Value; @@ -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, @@ -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, diff --git a/src/apps/cli/src/peer_host/commands/tools.rs b/src/apps/cli/src/peer_host/commands/tools.rs new file mode 100644 index 0000000000..24db666e0f --- /dev/null +++ b/src/apps/cli/src/peer_host/commands/tools.rs @@ -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 { + let tools = build_all_tools_info().await; + serde_json::to_value(tools).map_err(|error| format!("Failed to serialize tool info: {error}")) +} diff --git a/src/apps/cli/src/peer_host/control.rs b/src/apps/cli/src/peer_host/control.rs index c03c4ddb86..b35a87688f 100644 --- a/src/apps/cli/src/peer_host/control.rs +++ b/src/apps/cli/src/peer_host/control.rs @@ -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, }, }) } diff --git a/src/apps/cli/src/peer_host/deny.rs b/src/apps/cli/src/peer_host/deny.rs index 16701bec1c..2cfa80db8a 100644 --- a/src/apps/cli/src/peer_host/deny.rs +++ b/src/apps/cli/src/peer_host/deny.rs @@ -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. diff --git a/src/apps/cli/src/peer_host/dispatch.rs b/src/apps/cli/src/peer_host/dispatch.rs index 38435b0b3c..bd74a51f00 100644 --- a/src/apps/cli/src/peer_host/dispatch.rs +++ b/src/apps/cli/src/peer_host/dispatch.rs @@ -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:?}"), } @@ -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( diff --git a/src/apps/desktop/src/api/peer_host_invoke.rs b/src/apps/desktop/src/api/peer_host_invoke.rs index fcc2800946..3541f092cf 100644 --- a/src/apps/desktop/src/api/peer_host_invoke.rs +++ b/src/apps/desktop/src/api/peer_host_invoke.rs @@ -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", @@ -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>>> = @@ -364,10 +421,25 @@ pub async fn peer_mode_ping() -> Result { "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, }, })) } @@ -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") @@ -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] diff --git a/src/apps/desktop/src/api/tool_api.rs b/src/apps/desktop/src/api/tool_api.rs index 06dbc09ad0..8c8b281972 100644 --- a/src/apps/desktop/src/api/tool_api.rs +++ b/src/apps/desktop/src/api/tool_api.rs @@ -4,7 +4,6 @@ use log::error; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; -use std::sync::Arc; use tauri::State; use bitfun_agent_runtime::sdk::AgentUserAnswersRequest; @@ -14,6 +13,7 @@ use bitfun_core::agentic::{ workspace::{local_workspace_services, remote_workspace_services}, WorkspaceBinding, }; +use bitfun_core::agentic::tools::product_runtime::{build_tool_info, ToolInfoDto}; use bitfun_core::product_runtime::CoreRuntimeServicesProvider; use bitfun_core::service::remote_ssh::workspace_state::{ get_remote_workspace_manager, lookup_remote_connection, workspace_session_identity, @@ -22,6 +22,12 @@ use bitfun_core::util::elapsed_ms_u64; use crate::runtime::DesktopRuntimeContext; +/// Re-export the shared tool catalog DTO so callers see one `ToolInfo` type +/// across the Desktop Tauri command and the CLI Peer Host handler. Core owns +/// the shape; both hosts must answer `get_all_tools_info` with it so a +/// controller cannot tell "unsupported" from "empty". +pub type ToolInfo = ToolInfoDto; + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ToolExecutionRequest { @@ -38,34 +44,11 @@ pub struct GetToolInfoRequest { pub tool_name: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DynamicMcpToolInfo { - pub server_id: String, - pub server_name: String, - pub tool_name: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DynamicToolInfo { - pub provider_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub provider_kind: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub mcp: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolInfo { - pub name: String, - pub description: String, - pub input_schema: serde_json::Value, - pub is_readonly: bool, - pub is_concurrency_safe: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub dynamic_info: Option, -} +// Re-export the shared dynamic tool DTOs (Core already owns them under +// `bitfun_core::agentic::tools::framework`); Desktop used to carry byte-for-byte +// duplicates. Keeping the names re-exported preserves downstream `use ...::*` +// imports in lib.rs. +pub use bitfun_core::agentic::tools::framework::{DynamicMcpToolInfo, DynamicToolInfo}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolExecutionResponse { @@ -165,42 +148,6 @@ async fn build_tool_context(workspace_path: Option<&str>) -> ToolUseContext { ) } -fn to_dynamic_mcp_tool_info( - info: bitfun_core::agentic::tools::framework::DynamicMcpToolInfo, -) -> DynamicMcpToolInfo { - DynamicMcpToolInfo { - server_id: info.server_id, - server_name: info.server_name, - tool_name: info.tool_name, - } -} - -fn to_dynamic_tool_info( - info: bitfun_core::agentic::tools::framework::DynamicToolInfo, -) -> DynamicToolInfo { - DynamicToolInfo { - provider_id: info.provider_id, - provider_kind: info.provider_kind, - mcp: info.mcp.map(to_dynamic_mcp_tool_info), - } -} - -async fn build_tool_info(tool: &Arc) -> ToolInfo { - let description = tool - .description() - .await - .unwrap_or_else(|_| "No description available".to_string()); - - ToolInfo { - name: tool.name().to_string(), - description, - input_schema: tool.input_schema_for_model().await, - is_readonly: tool.is_readonly(), - is_concurrency_safe: tool.is_concurrency_safe(None), - dynamic_info: tool.dynamic_tool_info().map(to_dynamic_tool_info), - } -} - fn has_explicit_workspace_path(workspace_path: Option<&str>) -> bool { workspace_path.is_some_and(|path| !path.trim().is_empty()) } diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime.rs index de458d4028..fd1678389c 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime.rs @@ -31,6 +31,7 @@ pub(crate) use catalog::{ resolve_product_resolved_visible_tools, ProductGetToolSpecRuntime, ProductToolCatalogProvider, }; pub use catalog::{ResolvedToolManifest, ResolvedVisibleTools}; +pub use catalog::{build_all_tools_info, build_tool_info, ToolInfoDto}; pub use get_tool_spec_tool::GetToolSpecTool; pub(crate) use loaded_spec_state::collect_product_loaded_deferred_tool_specs; diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs index f83d5addd9..f62d9a241c 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs @@ -8,15 +8,68 @@ use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::types::ToolDefinition; use bitfun_agent_tools::{ resolve_contextual_tool_manifest, resolve_contextual_visible_tools, ContextualToolManifest, - ContextualVisibleTools, GetToolSpecCatalogProvider, GetToolSpecDeferredToolSummary, + ContextualVisibleTools, DynamicToolInfo, + GetToolSpecCatalogProvider, GetToolSpecDeferredToolSummary, GetToolSpecExecutionError, GetToolSpecRuntime, ToolCatalogRuntime, ToolCatalogSnapshotProvider, ToolManifestDefinition, CALL_DEFERRED_TOOL_NAME, GET_TOOL_SPEC_TOOL_NAME, }; +use serde::Serialize; use serde_json::Value; use std::sync::Arc; const DEFERRED_TOOL_LOADING_CONTEXT_KEY: &str = "enable_deferred_tool_loading"; +/// Read-only tool catalog DTO returned by `get_all_tools_info`. +/// +/// Owned by Core so both the Desktop Tauri command and the CLI Peer Host +/// `get_all_tools_info` handler return the same shape — a controller cannot +/// tell "CLI Host doesn't support catalog query" from "the runtime really has +/// no tools", and a Peer must not answer with a different DTO than Desktop. +/// Field names are snake_case to match the existing Web UI `ToolInfo` contract. +#[derive(Debug, Clone, Serialize)] +pub struct ToolInfoDto { + pub name: String, + pub description: String, + pub input_schema: Value, + pub is_readonly: bool, + pub is_concurrency_safe: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub dynamic_info: Option, +} + +/// Build the catalog DTO for one tool. +/// +/// Mirrors the former Desktop `build_tool_info` exactly: same description +/// fallback, same `input_schema_for_model`, same `is_concurrency_safe(None)`, +/// same `dynamic_tool_info`. Desktop now delegates here; CLI reuses the same +/// path so the two hosts never drift. +pub async fn build_tool_info(tool: &Arc) -> ToolInfoDto { + let description = tool + .description() + .await + .unwrap_or_else(|_| "No description available".to_string()); + ToolInfoDto { + name: tool.name().to_string(), + description, + input_schema: tool.input_schema_for_model().await, + is_readonly: tool.is_readonly(), + is_concurrency_safe: tool.is_concurrency_safe(None), + dynamic_info: tool.dynamic_tool_info(), + } +} + +/// Build the catalog DTO for every tool in the global registry, in registry +/// order. This is the Core-owned implementation behind the +/// `get_all_tools_info` HostInvoke command on both Desktop and CLI Peer Hosts. +pub async fn build_all_tools_info() -> Vec { + let tools = get_global_tool_registry().read().await.get_all_tools(); + let mut infos = Vec::with_capacity(tools.len()); + for tool in &tools { + infos.push(build_tool_info(tool).await); + } + infos +} + #[derive(Debug, Clone)] pub struct ResolvedToolManifest { pub allowed_tool_names: Vec, diff --git a/src/crates/assembly/core/src/product_runtime.rs b/src/crates/assembly/core/src/product_runtime.rs index 289815c99c..18825c7f13 100644 --- a/src/crates/assembly/core/src/product_runtime.rs +++ b/src/crates/assembly/core/src/product_runtime.rs @@ -844,6 +844,21 @@ impl CoreAgentRuntimeCompatibility { .map_err(|error| error.to_string()) } + /// Cancel a running tool execution on this host. + /// + /// The controller renders tool cards (e.g. the Terminal card's Interrupt + /// button) for Turns this host owns, so it must be able to stop a running + /// tool here. This is the per-tool interrupt contract behind the + /// `cancel_tool` HostInvoke command; both Desktop and CLI Peer Hosts reach + /// the same Core-owned coordinator the local UI does, one level finer than + /// `cancel_dialog_turn`. + pub async fn cancel_tool(&self, tool_id: &str, reason: String) -> Result<(), String> { + self.coordinator + .cancel_tool(tool_id, reason) + .await + .map_err(|error| error.to_string()) + } + /// Applies the same Core deployment owner before a product compatibility /// path attaches to or mutates a structured workspace scope. pub fn ensure_workspace_runtime_ownership( diff --git a/src/web-ui/eslint.config.mjs b/src/web-ui/eslint.config.mjs index 426751c7cf..5cbbff59c4 100644 --- a/src/web-ui/eslint.config.mjs +++ b/src/web-ui/eslint.config.mjs @@ -18,10 +18,58 @@ export default tseslint.config( 'src/**/*.example.tsx', 'src/component-library/components/registry.tsx', 'src/component-library/preview/**', - 'src/shared/context-system/core/types/**', - 'src/shared/context-menu-system/examples/**', ], }, + { + // Adapter-layer fence: business Tauri commands must reach the platform + // only through ApiClient (api.invoke). Direct `invoke` from + // '@tauri-apps/api/core' is reserved for the adapter implementations in + // adapters/** (and the peer-device host bridge, which intentionally runs + // outside the routed transport — see PeerHostInvokeBridge). This is the + // executable form of "front end calls go through the adapter layer"; + // reintroducing a direct invoke elsewhere fails the build. + files: ['src/**/*.{ts,tsx}'], + ignores: [ + 'src/infrastructure/api/adapters/**', + // PeerHostInvokeBridge runs on the HOST side of Peer-Device Mode: it + // executes *dynamic* command names forwarded from the peer device via a + // raw Tauri invoke. ApiClient is already routed to the peer adapter at + // this point, so routing through it would be wrong. This is the one + // intentional exception to the adapter-layer fence. + 'src/infrastructure/peer-device/PeerHostInvokeBridge.tsx', + ], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['@tauri-apps/api/core'], + importNames: ['invoke'], + message: + '业务命令必须经 api.invoke(ApiClient) 统一适配层,不可直接 import invoke。' + + '如需直连平台 invoke,放到 adapters/ 内并经 api 暴露。', + }, + ], + }, + ], + // no-restricted-imports only covers static ImportDeclaration in ESLint 9; + // dynamic `import('@tauri-apps/api/core')` to grab `invoke` bypasses it. + // Block the same surface with an ImportExpression selector so a future + // dynamic-import bypass fails the build too. Same ignores (adapters/** + + // PeerHostInvokeBridge) apply via this block's ignores; exceptions must be + // added with an owner comment, like the static rule. + 'no-restricted-syntax': [ + 'error', + { + selector: "ImportExpression[source.value='@tauri-apps/api/core']", + message: + '业务命令必须经 api.invoke(ApiClient) 统一适配层,不可动态 import invoke。' + + '如需直连平台 invoke,放到 adapters/ 内并经 api 暴露。', + }, + ], + }, + }, { files: ['src/**/*.{ts,tsx}'], extends: [js.configs.recommended, ...tseslint.configs.recommended], @@ -86,6 +134,19 @@ export default tseslint.config( ], }, }, + { + // Pre-existing legacy: context-system type impls use class components + // that call React Hooks (a pattern predating the adapter fence). Exempt + // ONLY the noisy legacy rule here — the adapter fence + // (no-restricted-imports / no-restricted-syntax) still applies to this + // directory, so a direct or dynamic `invoke` import from + // '@tauri-apps/api/core' here fails the build just like anywhere else. + // This was previously a global ignore that let the whole fence be bypassed. + files: ['src/shared/context-system/core/types/**/*.{ts,tsx}'], + rules: { + 'react-hooks/rules-of-hooks': 'off', + }, + }, { files: ['*.{ts,mts,cts}', '*.config.{ts,mts,cts}', 'vite.config.ts'], extends: [js.configs.recommended, ...tseslint.configs.recommended], diff --git a/src/web-ui/eslint.fence.regression.test.ts b/src/web-ui/eslint.fence.regression.test.ts new file mode 100644 index 0000000000..3c4aa40e0b --- /dev/null +++ b/src/web-ui/eslint.fence.regression.test.ts @@ -0,0 +1,122 @@ +/** + * Adapter-fence regression test. + * + * The `no-restricted-imports` / `no-restricted-syntax` rules in + * `eslint.config.mjs` block direct and dynamic `invoke` imports from + * `@tauri-apps/api/core` everywhere except `adapters/**` and the documented + * `PeerHostInvokeBridge` exception. PR #2428 review #3 found that a global + * `ignores` entry for `src/shared/context-system/core/types/**` let the whole + * fence be bypassed in that directory — a direct `invoke` there passed lint. + * + * This test pins that the fence now applies to: + * - an ordinary business directory (always did), and + * - the context-system types directory (the regression). + * + * It also pins that the adapter exception still permits direct `invoke` inside + * `adapters/**`. Run via `pnpm vitest run`. + */ +import { describe, expect, it } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { resolve } from 'node:path'; + +const webUiRoot = resolve(__dirname); + +interface ProbeCase { + name: string; + filename: string; + source: string; + expectError: boolean; +} + +const STATIC_PROBE = `import { invoke } from '@tauri-apps/api/core'; +export const run = () => invoke('probe'); +`; + +const DYNAMIC_PROBE = `const mod = await import('@tauri-apps/api/core'); +export const run = () => mod.invoke('probe'); +`; + +const cases: ProbeCase[] = [ + { + name: 'ordinary business dir: static invoke is blocked', + filename: 'src/app/__fence_probe_static.tsx', + source: STATIC_PROBE, + expectError: true, + }, + { + name: 'ordinary business dir: dynamic invoke is blocked', + filename: 'src/app/__fence_probe_dynamic.tsx', + source: DYNAMIC_PROBE, + expectError: true, + }, + { + name: 'context-system types dir: static invoke is blocked (regression)', + filename: 'src/shared/context-system/core/types/__fence_probe_static.tsx', + source: STATIC_PROBE, + expectError: true, + }, + { + name: 'context-system types dir: dynamic invoke is blocked (regression)', + filename: 'src/shared/context-system/core/types/__fence_probe_dynamic.tsx', + source: DYNAMIC_PROBE, + expectError: true, + }, + { + name: 'adapter dir: static invoke is allowed (exception)', + filename: 'src/infrastructure/api/adapters/__fence_probe_static.ts', + source: STATIC_PROBE, + expectError: false, + }, +]; + +/** + * Resolve the eslint CLI entry as an absolute path and run it with `node`, + * without a shell. Going through `pnpm`/`pnpm.cmd` needed `shell: true` on + * Windows (a `.cmd` shim cannot be spawned with `shell: false`), which triggers + * Node's DEP0190 security deprecation. Running the eslint JS entry directly + * via `node` keeps `shell: false` on every platform and avoids the warning. + */ +function eslintBinPath(): string { + return resolve(webUiRoot, 'node_modules/eslint/bin/eslint.js'); +} + +function lintProbe(probe: ProbeCase): { hasError: boolean; output: string } { + // --stdin + --stdin-filename make the rule's path selectors see the probe as + // if it lived at that path, so the fence applies per the probe's location. + const args = [ + eslintBinPath(), + '--stdin', + '--stdin-filename', + probe.filename, + ]; + const result = spawnSync(process.execPath, args, { + cwd: webUiRoot, + input: probe.source, + encoding: 'utf8', + shell: false, + }); + const combined = `${result.stdout ?? ''}${result.stderr ?? ''}`; + // ESLint exits non-zero and reports the restricted-imports/syntax error when + // the fence fires; a clean probe exits 0 with no error lines. + const hasError = /no-restricted-(imports|syntax)/.test(combined); + return { hasError, output: combined }; +} + +describe('adapter fence regression', () => { + for (const probe of cases) { + it(probe.name, () => { + const { hasError, output } = lintProbe(probe); + if (probe.expectError) { + expect( + hasError, + `expected the fence to block a direct/dynamic invoke at ${probe.filename}, but it did not:\n${output}`, + ).toBe(true); + } else { + expect( + hasError, + `expected the adapter exception to allow invoke at ${probe.filename}, but the fence fired:\n${output}`, + ).toBe(false); + } + }); + } +}); diff --git a/src/web-ui/src/app/App.tsx b/src/web-ui/src/app/App.tsx index 889bd4b6ff..b2fd17dd40 100644 --- a/src/web-ui/src/app/App.tsx +++ b/src/web-ui/src/app/App.tsx @@ -14,6 +14,7 @@ import { SessionUsageModal } from '../flow_chat/components/usage/SessionUsageMod import { createLogger } from '@/shared/utils/logger'; import { startupTrace } from '@/shared/utils/startupTrace'; import { isTauriRuntime } from '@/infrastructure/runtime'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import { useWorkspaceContext } from '../infrastructure/contexts/WorkspaceContext'; import { useGlobalSceneShortcuts } from './hooks/useGlobalSceneShortcuts'; import { useDebugInspector } from '@/infrastructure/debug/useDebugInspector'; @@ -225,8 +226,7 @@ function App() { mainWindowShownRef.current = true; try { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('show_main_window'); + await api.invoke('show_main_window'); log.debug('Main window shown', { reason }); startupTrace.markPhase('main_window_shown', { reason }); window.dispatchEvent(new CustomEvent('bitfun:main-window-shown', { detail: { reason } })); @@ -663,8 +663,7 @@ function App() { await openAgentCompanionSession(sessionId); try { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('show_main_window'); + await api.invoke('show_main_window'); } catch (error) { log.warn('Failed to show main window from Agent companion bubble', { sessionId, diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx index 3f82a5dfa3..4f0e21e6e5 100644 --- a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx +++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { emit, listen } from '@tauri-apps/api/event'; import { cursorPosition, getCurrentWindow } from '@tauri-apps/api/window'; import { aiExperienceConfigService, type AgentCompanionPetSelection, type AIExperienceSettings } from '@/infrastructure/config/services/AIExperienceConfigService'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import { ChatInputPixelPet, type ChatInputPixelPetMood } from '@/flow_chat/components/ChatInputPixelPet'; import type { ChatInputPetMood } from '@/flow_chat/utils/chatInputPetMood'; import type { @@ -412,11 +413,10 @@ export const AgentCompanionDesktopPet: React.FC = () => { return; } - void import('@tauri-apps/api/core') - .then(({ invoke }) => invoke('resize_agent_companion_desktop_pet', { + void api.invoke('resize_agent_companion_desktop_pet', { width: nextWidth, height: nextHeight, - })) + }) .catch(error => { log.warn('Failed to resize Agent companion window', error); }); @@ -568,8 +568,7 @@ export const AgentCompanionDesktopPet: React.FC = () => { const showMainWindowFromPet = useCallback(async () => { try { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('show_main_window'); + await api.invoke('show_main_window'); } catch (error) { log.warn('Failed to show main window from Agent companion pet', error); } @@ -824,12 +823,8 @@ export const AgentCompanionDesktopPet: React.FC = () => { const openTaskSession = async (task: AgentCompanionTaskStatus) => { try { - const [{ invoke }, { emit }] = await Promise.all([ - import('@tauri-apps/api/core'), - import('@tauri-apps/api/event'), - ]); await emit('agent-companion://open-session', { sessionId: task.sessionId }); - await invoke('show_main_window'); + await api.invoke('show_main_window'); } catch (error) { log.warn('Failed to open Agent companion task session', { sessionId: task.sessionId, diff --git a/src/web-ui/src/app/layout/AppLayout.tsx b/src/web-ui/src/app/layout/AppLayout.tsx index 32d94bd012..e0e98bc122 100644 --- a/src/web-ui/src/app/layout/AppLayout.tsx +++ b/src/web-ui/src/app/layout/AppLayout.tsx @@ -37,6 +37,7 @@ import { useSessionModeStore } from '../stores/sessionModeStore'; import { isMacOSDesktopRuntime } from '@/infrastructure/runtime'; import { flowChatSessionConfigForWorkspace } from '../utils/projectSessionWorkspace'; import { notificationService } from '@/shared/notification-system'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import { AppearanceBackgroundMediaLayer, appearanceRuntime, useAppearance } from '@/infrastructure/appearance'; import './AppLayout.scss'; @@ -445,10 +446,7 @@ const AppLayout: React.FC = ({ className = '' }) => { try { // Both macOS and Windows/Linux: Rust intercepts the native close request // and emits this event. We decide hide vs quit; persist interrupted turns only on quit. - const [{ listen }, { invoke }] = await Promise.all([ - import('@tauri-apps/api/event'), - import('@tauri-apps/api/core'), - ]); + const { listen } = await import('@tauri-apps/api/event'); const persistInterruptedTurnsForExit = async () => { try { @@ -466,7 +464,7 @@ const AppLayout: React.FC = ({ className = '' }) => { if (isMacOS) { // macOS always hides to keep the app alive in the dock. try { - await invoke('hide_main_window_after_close_request'); + await api.invoke('hide_main_window_after_close_request'); } catch (error) { log.error('Failed to hide main window after close request', error); } diff --git a/src/web-ui/src/app/scenes/agents/AgentsScene.test.tsx b/src/web-ui/src/app/scenes/agents/AgentsScene.test.tsx index 030c780218..5d40d38339 100644 --- a/src/web-ui/src/app/scenes/agents/AgentsScene.test.tsx +++ b/src/web-ui/src/app/scenes/agents/AgentsScene.test.tsx @@ -116,6 +116,7 @@ function mockAgentsList(overrides: Record = {}) { filteredAgents: [], loading: false, availableTools: [], + toolCatalogStatus: 'available', getModeProfile: () => null, getAgentSkills: () => [], getModeManageableSubagents: () => [], @@ -377,4 +378,51 @@ describeWithJsdom('AgentsScene', () => { expect(summary?.textContent).toBe('Read'); expect(summary?.textContent).not.toContain('mcp__github__list_issues'); }); + + it('surfaces an unsupported tool catalog in the tools tab instead of an empty list', async () => { + // When the host can't answer get_all_tools_info the tools tab must say so + // and disable editing, rather than rendering as "no tools". See PR #2428 + // round 5 #2. + const mode = { + key: 'mode::custom-mode', + id: 'custom-mode', + name: 'Custom mode', + description: 'General coding mode.', + isReadonly: false, + isReview: false, + toolCount: 1, + defaultTools: ['Read'], + defaultEnabled: true, + effectiveEnabled: true, + source: 'user', + agentKind: 'mode' as const, + capabilities: [], + }; + mockAgentsList({ + allAgents: [mode], + filteredAgents: [mode], + availableTools: [], + toolCatalogStatus: 'unsupported', + getModeConfig: () => ({ + profile_id: 'custom-mode', + enabled_tools: ['Read'], + default_tools: ['Read'], + }), + }); + const { default: AgentsScene } = await import('./AgentsScene'); + + await act(async () => { + root.render(); + }); + await act(async () => { + Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent === mode.name) + ?.click(); + }); + + const status = container.querySelector('[data-testid="agent-detail-tools-catalog-status"]'); + expect(status?.textContent).toContain('agentsOverview.toolsUnsupported'); + // The tool summary picker must not render — the catalog is not available. + expect(container.querySelector('[data-testid="agent-detail-tool-summary"]')).toBeNull(); + }); }); diff --git a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx index f92488e9fe..5b5094f15a 100644 --- a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx +++ b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx @@ -215,6 +215,7 @@ const AgentsHomeView: React.FC = () => { filteredAgents, loading, availableTools, + toolCatalogStatus, configuredModels = [], getModeProfile, getAgentSkills, @@ -236,6 +237,18 @@ const AgentsHomeView: React.FC = () => { t, }); + // Tool-catalog load state from the host (available / unsupported / failed / + // empty). When the host doesn't expose a catalog or the read failed, the + // tools tab must say so instead of rendering as "no tools". Writes are gated + // off too — toggling against a failed catalog would save a config the host + // can't act on. See PR #2428 round 5 #2. + const toolCatalogWritable = toolCatalogStatus === 'available' || toolCatalogStatus === 'empty'; + const toolCatalogMessage = toolCatalogStatus === 'unsupported' + ? t('agentsOverview.toolsUnsupported') + : toolCatalogStatus === 'failed' + ? t('agentsOverview.toolsFailed') + : null; + useGallerySceneAutoRefresh({ sceneId: 'agents', refetch: () => { @@ -582,7 +595,9 @@ const AgentsHomeView: React.FC = () => { await CustomAgentAPI.deleteCustomAgent(id, workspacePath || undefined); notification.success(t('agentsOverview.deleteSuccess', { name })); closeAgentDetails(); - await loadAgents(); + // CustomAgentAPI emits `custom-agent:updated` after the delete; the + // useAgentsList subscriber owns the single refresh so two overlapping + // catalog loads cannot race their status snapshots. } catch (e) { notification.error( `${t('agentsOverview.deleteFailed')}${e instanceof Error ? e.message : String(e)}`, @@ -590,7 +605,7 @@ const AgentsHomeView: React.FC = () => { } finally { setDeletingAgent(false); } - }, [selectedAgent, closeAgentDetails, loadAgents, notification, t, workspacePath]); + }, [selectedAgent, closeAgentDetails, notification, t, workspacePath]); const canManageCustomAgent = Boolean( selectedAgent @@ -1090,8 +1105,10 @@ const AgentsHomeView: React.FC = () => {