Skip to main content

forge_workspace/mcp/browser/
facade.rs

1//! The narrow surface the browser tools call: one call, sent to the server's
2//! browser fleet.
3//!
4//! Mirrors [`crate::mcp::review::facade`]: the `Tool` impls hold an
5//! `Arc<dyn BrowserFacade>` so their tests drive a `MockBrowserFacade`
6//! instead of a live fleet.
7
8use std::sync::Arc;
9
10use async_trait::async_trait;
11use forge_primitives::SessionSlot;
12use forge_primitives::browser::{BrowserPart, HandOffAnswer, HandOffEnding, HandOffSource};
13use serde_json::Value;
14
15use crate::browser::NO_BROWSER_HOST;
16use crate::workspace::Workspace;
17
18/// The fleet, as one tool call sees it.
19#[async_trait]
20pub trait BrowserFacade: Send + Sync {
21    /// Run one browser tool through the server's fleet, answering with the
22    /// parts it returned or the reason the call failed.
23    async fn call(
24        &self,
25        seat: &SessionSlot,
26        tool: &str,
27        args: Value,
28    ) -> Result<Vec<BrowserPart>, String>;
29
30    /// Ask the person at a client to act in one of the seat's browser tabs,
31    /// and wait for their word. `tab` is the page id the tool listed; the
32    /// fleet resolves it to the tab it raises.
33    ///
34    /// **No timeout.** The ask dock's own timelines say a held prompt sits for
35    /// hours, and the session is meant to wait as long as it takes; the one
36    /// refusal that does not wait is a stream nobody can answer on, which
37    /// fails closed rather than parking the session on a prompt no view can
38    /// draw.
39    async fn hand_off(
40        &self,
41        seat: &SessionSlot,
42        reason: &str,
43        tab: &str,
44    ) -> Result<HandOffEnding, String>;
45}
46
47/// Production impl: the server's fleet, and the workspace itself for the one
48/// tool that parks on the person rather than on the browser.
49pub struct ProdBrowserFacade {
50    workspace: std::sync::Weak<Workspace>,
51}
52
53impl ProdBrowserFacade {
54    pub fn from_workspace(workspace: &Arc<Workspace>) -> Arc<dyn BrowserFacade> {
55        Arc::new(Self { workspace: Arc::downgrade(workspace) })
56    }
57}
58
59#[async_trait]
60impl BrowserFacade for ProdBrowserFacade {
61    async fn call(
62        &self,
63        seat: &SessionSlot,
64        tool: &str,
65        args: Value,
66    ) -> Result<Vec<BrowserPart>, String> {
67        // **Read at call time**: the fleet is installed onto the workspace
68        // after boot wiring, and a facade holding the port from construction
69        // would answer every call from the state before it.
70        match self.workspace.upgrade().and_then(|workspace| workspace.browser_host()) {
71            Some(host) => host.call(seat, tool, args).await,
72            None => Err(NO_BROWSER_HOST.to_owned()),
73        }
74    }
75
76    async fn hand_off(
77        &self,
78        seat: &SessionSlot,
79        reason: &str,
80        tab: &str,
81    ) -> Result<HandOffEnding, String> {
82        let Some(workspace) = self.workspace.upgrade() else {
83            return Err("the workspace went away before the hand-off was staged".to_owned());
84        };
85        let (id, answer) = workspace.register_browser_hand_off(seat, reason, tab).await?;
86        let guard =
87            ResolveHandOffOnDrop { workspace: Arc::clone(&workspace), id, caller: seat.clone() };
88        let ending = answer.await.map_err(|_| {
89            // The fail-closed read: the stream could not take the update, so
90            // no view can show the dock - and holding the session on a prompt
91            // nobody can answer would park it forever.
92            "no attached client can show the browser hand-off".to_owned()
93        })?;
94        drop(guard);
95        Ok(ending)
96    }
97}
98
99/// Resolves a parked hand-off when the awaiting handler goes - a session that
100/// died mid-wait, never a decision. A resolve that already happened makes the
101/// drop a no-op, which is why the approved path can just let it fall.
102struct ResolveHandOffOnDrop {
103    workspace: Arc<Workspace>,
104    id: uuid::Uuid,
105    caller: SessionSlot,
106}
107
108impl Drop for ResolveHandOffOnDrop {
109    fn drop(&mut self) {
110        self.workspace.resolve_browser_hand_off(
111            self.id,
112            &self.caller,
113            HandOffAnswer {
114                ending: HandOffEnding::Abandoned,
115                source: HandOffSource::Abandoned,
116                client: None,
117            },
118        );
119    }
120}
121
122/// Mock for the tools' unit tests: captures what a call carried and answers
123/// with a preloaded outcome.
124#[cfg(test)]
125pub struct MockBrowserFacade {
126    /// Captured `(tool, args)` calls.
127    pub calls: parking_lot::Mutex<Vec<(String, Value)>>,
128    /// The seat each call was made for, so a caller sending the wrong one is a
129    /// failed assertion rather than a silent mismatch.
130    pub seats: parking_lot::Mutex<Vec<SessionSlot>>,
131    /// The outcome every call answers with.
132    pub answer: parking_lot::Mutex<Result<Vec<BrowserPart>, String>>,
133    /// What `hand_off` answers with, and what it was asked.
134    pub hand_off_answer: parking_lot::Mutex<Result<HandOffEnding, String>>,
135    /// Each hand-off's `(reason, page id)`.
136    pub hand_offs: parking_lot::Mutex<Vec<(String, String)>>,
137}
138
139#[cfg(test)]
140impl MockBrowserFacade {
141    pub fn new() -> Self {
142        Self {
143            calls: parking_lot::Mutex::new(Vec::new()),
144            seats: parking_lot::Mutex::new(Vec::new()),
145            answer: parking_lot::Mutex::new(Ok(vec![BrowserPart::Text {
146                text: "done".to_owned(),
147            }])),
148            hand_off_answer: parking_lot::Mutex::new(Ok(HandOffEnding::Done)),
149            hand_offs: parking_lot::Mutex::new(Vec::new()),
150        }
151    }
152
153    pub fn into_arc(self) -> Arc<dyn BrowserFacade> {
154        Arc::new(self)
155    }
156}
157
158#[cfg(test)]
159#[async_trait]
160impl BrowserFacade for MockBrowserFacade {
161    async fn call(
162        &self,
163        seat: &SessionSlot,
164        tool: &str,
165        args: Value,
166    ) -> Result<Vec<BrowserPart>, String> {
167        self.seats.lock().push(seat.clone());
168        self.calls.lock().push((tool.to_owned(), args));
169        self.answer.lock().clone()
170    }
171
172    async fn hand_off(
173        &self,
174        seat: &SessionSlot,
175        reason: &str,
176        tab: &str,
177    ) -> Result<HandOffEnding, String> {
178        self.seats.lock().push(seat.clone());
179        self.hand_offs.lock().push((reason.to_owned(), tab.to_owned()));
180        self.hand_off_answer.lock().clone()
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use crate::browser::testing::StubHost;
188    use tokio::sync::mpsc;
189
190    /// The production facade is the server's fleet: one call out, the host's
191    /// answer back, with the tool and args it was called with.
192    #[tokio::test]
193    async fn the_production_facade_sends_the_call_to_the_installed_host() {
194        let (workspace, _rx) = Workspace::testing_stub();
195        let (calls, mut heard) = mpsc::unbounded_channel();
196        workspace.install_browser_host(Arc::new(StubHost { calls }));
197        let facade = ProdBrowserFacade::from_workspace(&workspace);
198        let seat = SessionSlot::lead("TestOrg", "proj");
199
200        let answer =
201            facade.call(&seat, "browser_click", serde_json::json!({ "target": "e5" })).await;
202
203        assert_eq!(
204            heard.try_recv(),
205            Ok(("browser_click".to_owned(), serde_json::json!({ "target": "e5" }))),
206            "the host is told which tool, with its arguments verbatim",
207        );
208        assert_eq!(answer, Ok(vec![BrowserPart::Text { text: "clicked".to_owned() }]));
209    }
210
211    /// **The waiter going away is its own ending.** A session that dies (or a
212    /// handler dropped mid-wait) must clear the registry and tell the views
213    /// which ending took the hand-off, rather than leaving a dock up for a
214    /// prompt no answer can reach.
215    #[tokio::test]
216    async fn a_hand_off_waiter_that_dies_is_reported_as_abandoned() {
217        let (workspace, mut rx) = Workspace::testing_stub();
218        let (calls, _heard) = mpsc::unbounded_channel();
219        workspace.install_browser_host(Arc::new(StubHost { calls }));
220        let facade = ProdBrowserFacade::from_workspace(&workspace);
221        let seat = SessionSlot::lead("TestOrg", "proj");
222        let task = tokio::spawn({
223            let facade = Arc::clone(&facade);
224            let seat = seat.clone();
225            async move { facade.hand_off(&seat, "solve the CAPTCHA", "T2").await }
226        });
227        // The hand-off lands in the registry, which is what the blocked call
228        // just did.
229        for _ in 0..200 {
230            if workspace.browser_handoffs.lock().keys().next().is_some() {
231                break;
232            }
233            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
234        }
235        assert!(
236            workspace.browser_handoffs.lock().keys().next().is_some(),
237            "the blocked call parked a hand-off",
238        );
239
240        task.abort();
241        let _ = task.await;
242
243        let mut resolved = Vec::new();
244        while let Ok(frame) = rx.try_recv() {
245            if let crate::protocol::SessionUpdate::BrowserHandOffResolved { answer, .. } =
246                &*frame.update
247            {
248                resolved.push(answer.ending);
249            }
250        }
251        assert_eq!(
252            resolved,
253            vec![HandOffEnding::Abandoned],
254            "a dropped waiter clears the registry and says which ending took the hand-off",
255        );
256        assert!(workspace.browser_handoffs.lock().is_empty(), "and leaves nothing registered");
257    }
258
259    /// With no fleet on the workspace, the facade answers the named refusal
260    /// rather than inventing one of its own.
261    #[tokio::test]
262    async fn a_workspace_with_no_host_refuses_by_name() {
263        let (workspace, _rx) = Workspace::testing_stub();
264        let facade = ProdBrowserFacade::from_workspace(&workspace);
265        let refused = facade
266            .call(&SessionSlot::lead("TestOrg", "proj"), "browser_close", serde_json::json!({}))
267            .await;
268        assert_eq!(refused, Err(crate::browser::NO_BROWSER_HOST.to_owned()));
269    }
270
271    /// **A hand-off on a tab that is not the session's never reaches a
272    /// person.** The fleet's refusal is the tool call's, and no ask is
273    /// parked or drawn.
274    #[tokio::test]
275    async fn a_hand_off_on_an_unknown_tab_is_refused_by_name() {
276        let (workspace, mut rx) = Workspace::testing_stub();
277        let (calls, _heard) = mpsc::unbounded_channel();
278        workspace.install_browser_host(Arc::new(StubHost { calls }));
279        let facade = ProdBrowserFacade::from_workspace(&workspace);
280        let seat = SessionSlot::lead("TestOrg", "proj");
281
282        let refused = facade.hand_off(&seat, "look at this", "gone").await;
283
284        assert_eq!(refused, Err("no tab has the page id 'gone'".to_owned()));
285        assert!(workspace.browser_handoffs.lock().is_empty(), "nothing is parked");
286        assert!(rx.try_recv().is_err(), "and no view is told to draw a dock");
287    }
288}