|
| 1 | +//! Action-tool ack contract. |
| 2 | +//! |
| 3 | +//! MCP "action" tools used to return `OK` the instant they dispatched an event to the |
| 4 | +//! frontend. If the FE was stalled (modal blocking input, error pane up, race during |
| 5 | +//! startup), the action was silently dropped but the tool still reported success. |
| 6 | +//! Real QA hit this. To make MCP a trustworthy automation surface, every fire-and-forget |
| 7 | +//! action now waits for a small ack signal before returning. |
| 8 | +//! |
| 9 | +//! ## Signals |
| 10 | +//! |
| 11 | +//! - `GenerationAdvanced`: the `PaneStateStore` generation counter strictly advanced past |
| 12 | +//! a captured value. Use this for actions that mutate pane state (navigation, refresh, |
| 13 | +//! selection, view mode, sort, tabs, cursor moves, auto-confirmed copy/move/delete). |
| 14 | +//! - `SoftDialogAppeared`: a soft (overlay) dialog with the given ID appeared in the |
| 15 | +//! `SoftDialogTracker`. Use this for confirmation dialogs (transfer, delete, mkdir, |
| 16 | +//! mkfile) when `autoConfirm: false`. |
| 17 | +//! - `WindowAppeared` / `WindowDisappeared`: a Tauri webview window with the given label |
| 18 | +//! prefix appeared (or vanished). Use this for child windows (settings, file-viewer, |
| 19 | +//! about) and for `dialog close` actions. |
| 20 | +//! - `Any`: succeeds when any of the inner signals fire (logical OR). Used by |
| 21 | +//! `open_under_cursor` where opening a directory bumps the pane generation but opening |
| 22 | +//! a file launches a viewer window. |
| 23 | +//! |
| 24 | +//! ## Timeout |
| 25 | +//! |
| 26 | +//! Default `DEFAULT_ACK_TIMEOUT` = 1500 ms. Not exposed as an MCP-tool parameter — |
| 27 | +//! MCP clients shouldn't have to tune this, the value is a backend-side latency |
| 28 | +//! budget. Tunable per-call via the `Duration` argument to `wait_for_ack`. |
| 29 | +//! |
| 30 | +//! ## Decision/Why |
| 31 | +//! |
| 32 | +//! Polling cadence matches the existing `await` tool (250 ms for state checks, 100 ms |
| 33 | +//! for window checks, since window state changes are typically faster than full pane |
| 34 | +//! refreshes). The two loops aren't unified into a shared `poll_until` core yet: the |
| 35 | +//! `await` tool exposes a few extra knobs (per-pane conditions, after_generation gate, |
| 36 | +//! rich match summaries) that don't apply here, and the ack helper's loop is ~15 lines. |
| 37 | +//! Extracting now would be premature abstraction. Revisit if we add a third polling |
| 38 | +//! site or if the `await` tool grows AckSignal-shaped conditions. |
| 39 | +
|
| 40 | +use std::time::Duration; |
| 41 | + |
| 42 | +use tauri::{AppHandle, Manager, Runtime}; |
| 43 | + |
| 44 | +use super::ToolError; |
| 45 | +use crate::mcp::dialog_state::SoftDialogTracker; |
| 46 | +use crate::mcp::pane_state::PaneStateStore; |
| 47 | + |
| 48 | +/// Default ack budget. Backend-side latency budget; not a client-facing knob. |
| 49 | +pub const DEFAULT_ACK_TIMEOUT: Duration = Duration::from_millis(1500); |
| 50 | + |
| 51 | +/// Polling cadence for state-driven signals. Matches the existing `await` tool. |
| 52 | +const STATE_POLL_INTERVAL: Duration = Duration::from_millis(250); |
| 53 | + |
| 54 | +/// Polling cadence for window/dialog appearance signals. Windows show up faster than |
| 55 | +/// full pane state pushes, so we poll a bit tighter for snappier acks. |
| 56 | +const WINDOW_POLL_INTERVAL: Duration = Duration::from_millis(100); |
| 57 | + |
| 58 | +/// What the backend should wait for to consider an action "actually processed." |
| 59 | +pub enum AckSignal { |
| 60 | + /// State generation strictly advanced past `from`. |
| 61 | + GenerationAdvanced { from: u64 }, |
| 62 | + /// A soft dialog with this ID appeared in `SoftDialogTracker`. |
| 63 | + SoftDialogAppeared(&'static str), |
| 64 | + /// A Tauri webview window whose label equals (or starts with, for viewers) |
| 65 | + /// the given pattern appeared. |
| 66 | + WindowAppeared(&'static str), |
| 67 | + /// A Tauri webview window matching the pattern vanished. |
| 68 | + WindowDisappeared(&'static str), |
| 69 | + /// Succeeds when any inner signal fires. Used for tools where the ack |
| 70 | + /// kind depends on what got opened (for example `open_under_cursor`). |
| 71 | + Any(Vec<AckSignal>), |
| 72 | +} |
| 73 | + |
| 74 | +impl AckSignal { |
| 75 | + /// Human-readable description for error messages. |
| 76 | + fn describe(&self) -> String { |
| 77 | + match self { |
| 78 | + AckSignal::GenerationAdvanced { from } => { |
| 79 | + format!("pane state generation > {from}") |
| 80 | + } |
| 81 | + AckSignal::SoftDialogAppeared(id) => format!("soft dialog '{id}' opened"), |
| 82 | + AckSignal::WindowAppeared(label) => format!("window '{label}' opened"), |
| 83 | + AckSignal::WindowDisappeared(label) => format!("window '{label}' closed"), |
| 84 | + AckSignal::Any(signals) => { |
| 85 | + let parts: Vec<String> = signals.iter().map(|s| s.describe()).collect(); |
| 86 | + format!("any of [{}]", parts.join(", ")) |
| 87 | + } |
| 88 | + } |
| 89 | + } |
| 90 | +} |
| 91 | + |
| 92 | +/// Wait for an ack signal to arrive within `timeout`. |
| 93 | +/// |
| 94 | +/// On success returns `Ok(())`. On timeout returns a `ToolError::internal` whose message |
| 95 | +/// names the missing signal and the elapsed budget, so callers can surface a useful |
| 96 | +/// failure rather than a false-positive OK. |
| 97 | +pub async fn wait_for_ack<R: Runtime>( |
| 98 | + app: &AppHandle<R>, |
| 99 | + signal: AckSignal, |
| 100 | + timeout: Duration, |
| 101 | +) -> Result<(), ToolError> { |
| 102 | + let start = tokio::time::Instant::now(); |
| 103 | + let deadline = start + timeout; |
| 104 | + |
| 105 | + // Pick the tighter cadence if any leaf signal is window-driven; this matters |
| 106 | + // for `Any` mixtures (open_under_cursor) where we want to react to a viewer |
| 107 | + // window as fast as a pane generation bump. |
| 108 | + let poll_interval = if signal_uses_windows(&signal) { |
| 109 | + WINDOW_POLL_INTERVAL |
| 110 | + } else { |
| 111 | + STATE_POLL_INTERVAL |
| 112 | + }; |
| 113 | + |
| 114 | + loop { |
| 115 | + if check_signal(app, &signal) { |
| 116 | + return Ok(()); |
| 117 | + } |
| 118 | + |
| 119 | + if tokio::time::Instant::now() >= deadline { |
| 120 | + let elapsed_ms = start.elapsed().as_millis(); |
| 121 | + return Err(ToolError::internal(format!( |
| 122 | + "Action not acknowledged by backend within {} ms (waiting for: {}). The frontend may be stalled (modal blocking input, error pane up, race during startup). Inspect cmdr://state to triage.", |
| 123 | + elapsed_ms, |
| 124 | + signal.describe() |
| 125 | + ))); |
| 126 | + } |
| 127 | + |
| 128 | + tokio::time::sleep(poll_interval).await; |
| 129 | + } |
| 130 | +} |
| 131 | + |
| 132 | +/// Check whether the signal is currently satisfied. Pure read; no side effects. |
| 133 | +fn check_signal<R: Runtime>(app: &AppHandle<R>, signal: &AckSignal) -> bool { |
| 134 | + match signal { |
| 135 | + AckSignal::GenerationAdvanced { from } => app |
| 136 | + .try_state::<PaneStateStore>() |
| 137 | + .map(|store| store.get_generation() > *from) |
| 138 | + .unwrap_or(false), |
| 139 | + AckSignal::SoftDialogAppeared(id) => app |
| 140 | + .try_state::<SoftDialogTracker>() |
| 141 | + .map(|tracker| tracker.get_open_types().iter().any(|d| d == id)) |
| 142 | + .unwrap_or(false), |
| 143 | + AckSignal::WindowAppeared(pattern) => window_matches(app, pattern), |
| 144 | + AckSignal::WindowDisappeared(pattern) => !window_matches(app, pattern), |
| 145 | + AckSignal::Any(signals) => signals.iter().any(|s| check_signal(app, s)), |
| 146 | + } |
| 147 | +} |
| 148 | + |
| 149 | +/// True if any Tauri webview window has a label exactly equal to `pattern`, |
| 150 | +/// or (for the `viewer` family) starting with `pattern-`. |
| 151 | +fn window_matches<R: Runtime>(app: &AppHandle<R>, pattern: &str) -> bool { |
| 152 | + let windows = app.webview_windows(); |
| 153 | + // `viewer-` is a label prefix family — each opened file has its own |
| 154 | + // `viewer-<id>` window. Other tracked labels (settings, about) are exact. |
| 155 | + if pattern == "viewer" { |
| 156 | + windows.keys().any(|k| k.starts_with("viewer-")) |
| 157 | + } else { |
| 158 | + windows.contains_key(pattern) |
| 159 | + } |
| 160 | +} |
| 161 | + |
| 162 | +/// Whether any leaf in the signal tree references windows. Drives poll cadence. |
| 163 | +fn signal_uses_windows(signal: &AckSignal) -> bool { |
| 164 | + match signal { |
| 165 | + AckSignal::WindowAppeared(_) | AckSignal::WindowDisappeared(_) => true, |
| 166 | + AckSignal::Any(signals) => signals.iter().any(signal_uses_windows), |
| 167 | + _ => false, |
| 168 | + } |
| 169 | +} |
| 170 | + |
| 171 | +/// Capture the current pane-state generation. Used to build a |
| 172 | +/// `GenerationAdvanced { from }` signal just before dispatching an action. |
| 173 | +/// |
| 174 | +/// Returns 0 when the store isn't registered (test contexts); callers wrap the |
| 175 | +/// resulting signal in a normal `wait_for_ack` call that will immediately succeed |
| 176 | +/// in those cases because the test fixture either bumps generation or skips the wait. |
| 177 | +pub fn snapshot_generation<R: Runtime>(app: &AppHandle<R>) -> u64 { |
| 178 | + app.try_state::<PaneStateStore>() |
| 179 | + .map(|store| store.get_generation()) |
| 180 | + .unwrap_or(0) |
| 181 | +} |
| 182 | + |
| 183 | +#[cfg(test)] |
| 184 | +mod tests { |
| 185 | + use super::*; |
| 186 | + use std::sync::Arc; |
| 187 | + |
| 188 | + // The signal-checking core is pure (it reads `Manager::try_state` and |
| 189 | + // `webview_windows`), so the only piece we can unit-test without spinning |
| 190 | + // up a Tauri app is the `PaneStateStore` interaction. The `tests/` |
| 191 | + // module covers that case via `tests::ack_system_tests` against a real |
| 192 | + // `PaneStateStore`. The window-driven branch is exercised by E2E tests |
| 193 | + // and by the dialog integration tests. |
| 194 | + |
| 195 | + #[test] |
| 196 | + fn describe_renders_each_variant() { |
| 197 | + assert!(AckSignal::GenerationAdvanced { from: 42 }.describe().contains("42")); |
| 198 | + assert!( |
| 199 | + AckSignal::SoftDialogAppeared("delete-confirmation") |
| 200 | + .describe() |
| 201 | + .contains("delete-confirmation") |
| 202 | + ); |
| 203 | + assert!(AckSignal::WindowAppeared("settings").describe().contains("settings")); |
| 204 | + assert!(AckSignal::WindowDisappeared("settings").describe().contains("settings")); |
| 205 | + let any = AckSignal::Any(vec![ |
| 206 | + AckSignal::GenerationAdvanced { from: 1 }, |
| 207 | + AckSignal::WindowAppeared("viewer"), |
| 208 | + ]); |
| 209 | + let s = any.describe(); |
| 210 | + assert!(s.contains("any of")); |
| 211 | + assert!(s.contains("viewer")); |
| 212 | + } |
| 213 | + |
| 214 | + #[test] |
| 215 | + fn signal_uses_windows_picks_tighter_cadence() { |
| 216 | + assert!(!signal_uses_windows(&AckSignal::GenerationAdvanced { from: 0 })); |
| 217 | + assert!(signal_uses_windows(&AckSignal::WindowAppeared("settings"))); |
| 218 | + assert!(signal_uses_windows(&AckSignal::Any(vec![ |
| 219 | + AckSignal::GenerationAdvanced { from: 0 }, |
| 220 | + AckSignal::WindowAppeared("viewer"), |
| 221 | + ]))); |
| 222 | + } |
| 223 | + |
| 224 | + // Verifies the core promise: once the generation strictly advances past |
| 225 | + // the snapshot, a future polling for `GenerationAdvanced` would return |
| 226 | + // true. We exercise this through the store directly because we can't |
| 227 | + // construct a real `AppHandle` here. |
| 228 | + #[test] |
| 229 | + fn generation_strictly_advances_after_set_left() { |
| 230 | + let store = Arc::new(PaneStateStore::new()); |
| 231 | + let before = store.get_generation(); |
| 232 | + // Snapshot before mutation |
| 233 | + let snapshot = before; |
| 234 | + // Mutate |
| 235 | + store.set_left(crate::mcp::pane_state::PaneState { |
| 236 | + path: "/tmp".to_string(), |
| 237 | + ..Default::default() |
| 238 | + }); |
| 239 | + assert!( |
| 240 | + store.get_generation() > snapshot, |
| 241 | + "generation should strictly advance after set_left" |
| 242 | + ); |
| 243 | + } |
| 244 | +} |
0 commit comments