forge_workspace/mcp/browser/
facade.rs1use 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#[async_trait]
20pub trait BrowserFacade: Send + Sync {
21 async fn call(
24 &self,
25 seat: &SessionSlot,
26 tool: &str,
27 args: Value,
28 ) -> Result<Vec<BrowserPart>, String>;
29
30 async fn hand_off(
40 &self,
41 seat: &SessionSlot,
42 reason: &str,
43 tab: &str,
44 ) -> Result<HandOffEnding, String>;
45}
46
47pub 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 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 "no attached client can show the browser hand-off".to_owned()
93 })?;
94 drop(guard);
95 Ok(ending)
96 }
97}
98
99struct 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#[cfg(test)]
125pub struct MockBrowserFacade {
126 pub calls: parking_lot::Mutex<Vec<(String, Value)>>,
128 pub seats: parking_lot::Mutex<Vec<SessionSlot>>,
131 pub answer: parking_lot::Mutex<Result<Vec<BrowserPart>, String>>,
133 pub hand_off_answer: parking_lot::Mutex<Result<HandOffEnding, String>>,
135 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 #[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 #[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 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 #[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 #[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}