Skip to main content

forge_workspace/mcp/
browser.rs

1//! The browser family: the core `@playwright/mcp` tools, forwarded to the
2//! server's browser fleet.
3//!
4//! **Names and argument shapes are upstream's, exactly.** A prompt written
5//! against a Playwright MCP server names `browser_navigate` and passes a
6//! snapshot's `target` ref, so the tools register unprefixed, with upstream's
7//! argument schemas - transcribed from `@playwright/mcp` 0.0.83's
8//! `tools/list`, the capture preserved with the V1 spec. Anything this family
9//! added on top (a hand-off `tab`, a forced click) would be a shape upstream
10//! does not have and a prompt that means something else here. Two
11//! descriptions differ: the close's is this family's own, because a close
12//! here closes the session's current tab, and the snapshot's gains the
13//! sentences on a ref's lifetime and on driving a page that keeps changing.
14//!
15//! **The family is any-caller, and it is not a toggleable one.** The browser
16//! is one machine-global one, not a scope like reviews or crons: every
17//! session may drive it, with one exception - `browser_end_host` belongs to a
18//! project's lead, because it ends a host every session on the profile
19//! shares, and a worker is refused by name. A workspace with no fleet answers
20//! the no-host refusal rather than pretending the surface is absent.
21//!
22//! **The tool carries no logic.** It forwards the call to the fleet and
23//! hands back the parts it returned - refs only mean something inside that
24//! browser's own page state, so the server drives the page rather than
25//! answering for it.
26
27use std::sync::Arc;
28
29use forge_primitives::SessionSlot;
30use forge_primitives::browser::{BrowserPart, HandOffEnding};
31use forge_sdk::mcp::server::McpServerBuilder;
32use forge_sdk::mcp::tool::{Tool, ToolInput, ToolOutput, ToolOutputBlock};
33
34#[cfg(test)]
35use forge_sdk::mcp::server::McpServer;
36use serde_json::Value;
37
38use crate::mcp::browser::facade::BrowserFacade;
39
40pub mod facade;
41
42pub mod specs;
43
44use specs::ToolSpec;
45
46/// Build a standalone `forge` MCP server carrying only the browser tools.
47/// Test-only; production shares one `forge` server via
48/// [`crate::mcp::build_forge_server`].
49#[cfg(test)]
50pub fn build_server(facade: Arc<dyn BrowserFacade>, slot: SessionSlot) -> McpServer {
51    add_tools(McpServerBuilder::new("forge", env!("CARGO_PKG_VERSION")), facade, slot).build()
52}
53
54/// Attach the browser tools to an existing builder, so they share the
55/// `forge` server name with every other family.
56///
57/// The last spec takes the caller's own handles: every tool keeps a handle of
58/// its own, and nothing is left holding the originals once they are all
59/// registered.
60pub(crate) fn add_tools(
61    builder: McpServerBuilder,
62    facade: Arc<dyn BrowserFacade>,
63    slot: SessionSlot,
64) -> McpServerBuilder {
65    let mut builder = builder;
66    let mut specs = specs::specs().into_iter().peekable();
67    while let Some(spec) = specs.next() {
68        if specs.peek().is_none() {
69            return builder.tool(BrowserTool { spec, facade, slot });
70        }
71        builder =
72            builder.tool(BrowserTool { spec, facade: Arc::clone(&facade), slot: slot.clone() });
73    }
74    builder
75}
76
77/// One registered tool: upstream's spec, plus the caller it acts for.
78struct BrowserTool {
79    spec: ToolSpec,
80    facade: Arc<dyn BrowserFacade>,
81    slot: SessionSlot,
82}
83
84#[async_trait::async_trait]
85impl Tool for BrowserTool {
86    fn name(&self) -> &'static str {
87        self.spec.name
88    }
89
90    fn description(&self) -> &'static str {
91        self.spec.description
92    }
93
94    fn input_schema(&self) -> Value {
95        self.spec.schema.clone()
96    }
97
98    async fn call(&self, input: ToolInput) -> ToolOutput {
99        let started = std::time::Instant::now();
100        let outcome = match (self.spec.name, self.slot.is_lead()) {
101            // **The end-host door is the lead's**: it takes every session's
102            // tabs on the profile, so a worker asks its lead rather than
103            // ending a host other sessions are using. The refusal is an
104            // outcome like any other, so the record below counts it.
105            ("browser_end_host", false) => {
106                Err("browser_end_host is the project lead's: it ends the browser host shared by \
107                 every session on the profile this project resolves to, including sessions in \
108                 other projects. Ask your lead to call it."
109                    .to_owned())
110            }
111            ("browser_hand_off", _) => self.hand_off(input.value).await,
112            _ => self.facade.call(&self.slot, self.spec.name, input.value).await,
113        };
114        // The record of calls: what ran, for whom, and how it ended. `debug`
115        // because a browser call is the session's own work rather than a
116        // problem forge has.
117        tracing::debug!(
118            target: "forge_workspace::browser",
119            event_name = "browser_call",
120            tool = self.spec.name,
121            slot = %self.slot.display(),
122            ok = outcome.is_ok(),
123            ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
124            "a browser tool call returned",
125        );
126        match outcome {
127            Ok(parts) => parts_output(parts),
128            Err(why) => ToolOutput::error(why),
129        }
130    }
131}
132
133impl BrowserTool {
134    /// **The one tool whose logic is the core's own.** Every other tool
135    /// forwards to the host because a `target` ref only means something in
136    /// the client's page state; the hand-off parks on the person, and the
137    /// wait - with no timeout - is the answer.
138    async fn hand_off(&self, args: Value) -> Result<Vec<BrowserPart>, String> {
139        // **An ask with no words is no ask**: a blank reason parks on the
140        // person a prompt that says nothing, so it is refused here rather
141        // than drawn as an empty row.
142        let Some(reason) =
143            args.get("reason").and_then(Value::as_str).filter(|reason| !reason.trim().is_empty())
144        else {
145            return Err("browser_hand_off needs `reason`: what the person should do".to_owned());
146        };
147        if args.get("tab").is_none() && args.get("index").is_some() {
148            return Err(
149                "browser_hand_off takes `tab`, the page id its listing prints first; `index` is \
150                 gone"
151                    .to_owned(),
152            );
153        }
154        let tab = match args.get("tab") {
155            Some(Value::String(tab)) if !tab.trim().is_empty() => {
156                let tab = tab.trim();
157                if !is_page_id(tab) {
158                    return Err(format!(
159                        "browser_hand_off needs `tab` as the page id browser_tabs prints first \
160                         in its row: exactly 32 hex characters, not '{tab}'. If your tool list \
161                         shows `tab` as a number, only a fresh session in this slot refreshes it."
162                    ));
163                }
164                tab
165            }
166            Some(Value::String(_)) => return Err("a page id cannot be blank".to_owned()),
167            Some(_) => {
168                return Err(
169                    "browser_hand_off needs `tab` as a page id string, not a number. If your \
170                     tool list shows `tab` as a number it was recorded before forge addressed \
171                     tabs by page id: a forge restart does not refresh it, and only a fresh \
172                     session in this slot picks up the current schema."
173                        .to_owned(),
174                );
175            }
176            None => {
177                return Err(
178                    "browser_hand_off needs `tab`, the page id string of one of this session's \
179                     tabs, from browser_tabs list"
180                        .to_owned(),
181                );
182            }
183        };
184        match self.facade.hand_off(&self.slot, reason, tab).await? {
185            HandOffEnding::Done => Ok(vec![BrowserPart::Text {
186                text: "The person is done in the browser; carry on from where you left off."
187                    .to_owned(),
188            }]),
189            HandOffEnding::NotNow => Err(
190                "The person said not now; the browser is as it was. Ask them again later if you \
191                 still need them on the page."
192                    .to_owned(),
193            ),
194            // **The person says something is off**: their words are the
195            // message beside this result, so the answer is a refusal that
196            // tells the agent to address them and hand the page off again -
197            // never "carry on".
198            HandOffEnding::Feedback => {
199                Err("The person says something is off. This is not approval: their words are the \
200                  message beside this result. Address them and hand the page off again for \
201                  another answer."
202                    .to_owned())
203            }
204            // The waiter cannot outlive a drop that sends this, so seeing it
205            // here would mean the registry and the waiter disagree.
206            HandOffEnding::Abandoned => Err("The hand-off ended without an answer.".to_owned()),
207        }
208    }
209}
210
211/// A page id as `browser_tabs` prints it: the page's CDP target, 32 hex
212/// characters. A stale seat's numeric schema has produced a digit run instead
213/// (`DD241...` arrived as `241`), so anything of another shape is refused
214/// before it can name a page.
215fn is_page_id(tab: &str) -> bool {
216    tab.len() == 32 && tab.bytes().all(|byte| byte.is_ascii_hexdigit())
217}
218
219/// What a host's answer becomes on the tool's way back: one content block per
220/// part, in order.
221fn parts_output(parts: Vec<BrowserPart>) -> ToolOutput {
222    let blocks = parts
223        .into_iter()
224        .map(|part| match part {
225            BrowserPart::Text { text } => ToolOutputBlock::Text { text },
226            BrowserPart::Image { mime_type, bytes } => {
227                ToolOutputBlock::Image { mime_type, data: bytes }
228            }
229        })
230        .collect();
231    ToolOutput { blocks, is_error: false }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use crate::mcp::browser::facade::MockBrowserFacade;
238    use serde_json::json;
239
240    fn seat() -> SessionSlot {
241        SessionSlot::from_str_for_test("caller")
242    }
243
244    /// A page id the way `browser_tabs` prints one - the CDP target, 32 hex
245    /// characters. A short stand-in would now be refused by the shape check.
246    const PAGE_ID: &str = "0123456789ABCDEF0123456789ABCDEF";
247
248    /// Every tool name the server registered, read off its debug listing.
249    fn names(facade: Arc<dyn BrowserFacade>) -> Vec<String> {
250        let debug = format!("{:?}", build_server(facade, seat()));
251        let (_, tools) = debug.split_once("tools: [").expect("debug lists the tool names");
252        let (tools, _) = tools.split_once(']').expect("the tool list is closed");
253        tools
254            .split(", ")
255            .map(|name| name.trim_matches('"').to_owned())
256            .filter(|name| !name.is_empty())
257            .collect()
258    }
259
260    /// The registered surface is the whole table, UNPREFIXED: a prompt
261    /// written against a Playwright MCP server names `browser_navigate`, and
262    /// a `browser__*` prefix or a renamed tool is a prompt that no longer
263    /// resolves. Every name the table carries registers, and nothing else
264    /// does.
265    ///
266    /// Sorted rather than in upstream's order, because the server hands its
267    /// tools out of a map: what is pinned here is the SET that resolves,
268    /// against the table read as a set too, so a tool added to one and not
269    /// the other fails.
270    #[test]
271    fn the_whole_surface_registers_with_the_tables_names() {
272        let mut registered = names(MockBrowserFacade::new().into_arc());
273        registered.sort();
274        let mut expected: Vec<&str> = specs::specs().iter().map(|spec| spec.name).collect();
275        expected.sort_unstable();
276        assert_eq!(
277            registered, expected,
278            "the registered names are the table's, and nothing else's"
279        );
280        assert!(
281            registered.iter().all(|name| !name.contains("__")),
282            "every browser tool crosses unprefixed: {registered:?}",
283        );
284    }
285
286    /// **The three shapes the live capture corrected**, and the arguments
287    /// this family adds beside upstream's - each transcribed independently
288    /// of the table in [`specs`], so the two agreeing is the check rather
289    /// than a shape pinned twice. A dropped argument here is a call the CLI
290    /// refuses before it is ever sent; an extra one upstream does not have is
291    /// a prompt that means something else here.
292    #[test]
293    fn the_corrected_shapes_and_the_added_arguments_are_what_the_capture_says() {
294        const EXPECTED: &[(&str, &[&str], &[&str])] = &[
295            // `scale` is REQUIRED on a screenshot, which the prose
296            // transcription missed and the live schema does not.
297            (
298                "browser_take_screenshot",
299                &["element", "target", "type", "filename", "fullPage", "scale"],
300                &["scale"],
301            ),
302            // `filename` is on evaluate, which the prose transcription missed.
303            ("browser_evaluate", &["element", "target", "function", "filename"], &["function"]),
304            // And run_code_unsafe marks nothing required.
305            ("browser_run_code_unsafe", &["code", "filename"], &[]),
306            // Beyond upstream, on tools upstream already has.
307            (
308                "browser_click",
309                &["element", "target", "doubleClick", "button", "modifiers", "force"],
310                &["target"],
311            ),
312            ("browser_wait_for", &["time", "text", "textGone", "expression"], &[]),
313        ];
314        let all = specs::specs();
315        for (name, properties, required) in EXPECTED {
316            let spec = all
317                .iter()
318                .find(|spec| spec.name == *name)
319                .unwrap_or_else(|| panic!("{name} is not on the surface"));
320            let schema = &spec.schema;
321            let mut got: Vec<&str> = schema["properties"]
322                .as_object()
323                .expect("a schema describes an object")
324                .keys()
325                .map(String::as_str)
326                .collect();
327            got.sort_unstable();
328            let mut want: Vec<&str> = properties.to_vec();
329            want.sort_unstable();
330            assert_eq!(got, want, "{name}: the argument names are the capture's");
331
332            let got_required: Vec<&str> = schema["required"]
333                .as_array()
334                .map_or_else(Vec::new, |list| list.iter().filter_map(Value::as_str).collect());
335            assert_eq!(got_required, *required, "{name}: the required arguments are the capture's");
336        }
337    }
338
339    /// One tool of the table, by name: a test that names the tool it drives
340    /// cannot be moved onto another one by a reordering of the surface.
341    fn spec_named(name: &str) -> ToolSpec {
342        specs::specs()
343            .into_iter()
344            .find(|spec| spec.name == name)
345            .unwrap_or_else(|| panic!("{name} is not on the surface"))
346    }
347
348    /// A call reaches the host with the arguments the CLI sent, and the
349    /// caller's own slot rides along - the client shows who drives what.
350    #[tokio::test]
351    async fn a_call_carries_the_arguments_and_the_callers_slot() {
352        let mock = Arc::new(MockBrowserFacade::new());
353        let tool = BrowserTool {
354            spec: spec_named("browser_navigate"),
355            facade: Arc::clone(&mock) as Arc<dyn BrowserFacade>,
356            slot: seat(),
357        };
358
359        let out = tool.call(ToolInput { value: json!({"url": "https://example.com"}) }).await;
360
361        assert!(!out.is_error, "the mock's answer is a result: {:?}", out.blocks);
362        assert_eq!(
363            mock.calls.lock().as_slice(),
364            [("browser_navigate".to_owned(), json!({"url": "https://example.com"}))],
365            "the host is asked for the tool the model called, with its arguments verbatim",
366        );
367        assert_eq!(
368            mock.seats.lock().as_slice(),
369            [seat()],
370            "and for the seat that called, so a wrong slot cannot show the wrong session as the asker",
371        );
372        assert_eq!(
373            out.blocks,
374            vec![ToolOutputBlock::Text { text: "done".to_owned() }],
375            "and the host's parts are what the tool returns",
376        );
377    }
378
379    /// A refused call is a tool error naming the reason, not an empty result
380    /// or a hang.
381    #[tokio::test]
382    async fn a_refused_call_is_a_tool_error_naming_the_reason() {
383        let mock = Arc::new(MockBrowserFacade::new());
384        *mock.answer.lock() = Err(crate::browser::NO_BROWSER_HOST.to_owned());
385        let tool = BrowserTool {
386            spec: spec_named("browser_navigate"),
387            facade: Arc::clone(&mock) as Arc<dyn BrowserFacade>,
388            slot: seat(),
389        };
390
391        let out = tool.call(ToolInput { value: json!({"url": "https://example.com"}) }).await;
392
393        assert!(out.is_error, "the refusal is the call's failure");
394        assert_eq!(
395            out.blocks,
396            vec![ToolOutputBlock::Text { text: crate::browser::NO_BROWSER_HOST.to_owned() }],
397            "and it carries the reason a session can act on",
398        );
399    }
400
401    /// **The end-host door belongs to the lead.** It ends the browser host
402    /// every session on the profile shares, so a worker is refused in words
403    /// and the fleet is never asked; the project's lead is the caller that
404    /// can take every session's tabs.
405    #[tokio::test]
406    async fn the_end_host_door_is_the_leads_and_reaches_the_fleet() {
407        let mock = Arc::new(MockBrowserFacade::new());
408        let worker = BrowserTool {
409            spec: spec_named("browser_end_host"),
410            facade: Arc::clone(&mock) as Arc<dyn BrowserFacade>,
411            slot: seat(),
412        };
413
414        let out = worker.call(ToolInput { value: json!({}) }).await;
415
416        assert!(out.is_error, "a worker cannot end a host other sessions are using");
417        assert!(mock.calls.lock().is_empty(), "the fleet is never asked");
418        let refusal = match out.blocks.as_slice() {
419            [ToolOutputBlock::Text { text }] => text.clone(),
420            other => panic!("the refusal is one text block: {other:?}"),
421        };
422        assert!(refusal.contains("lead"), "the refusal names who can end it: {refusal}");
423
424        let lead = BrowserTool {
425            spec: spec_named("browser_end_host"),
426            facade: Arc::clone(&mock) as Arc<dyn BrowserFacade>,
427            slot: SessionSlot::lead("TestOrg", "test-project"),
428        };
429
430        let out = lead.call(ToolInput { value: json!({}) }).await;
431
432        assert!(!out.is_error, "the lead's call reaches the fleet: {:?}", out.blocks);
433        assert_eq!(
434            mock.calls.lock().as_slice(),
435            [("browser_end_host".to_owned(), json!({}))],
436            "the door is the tool the fleet hears",
437        );
438    }
439
440    /// A record capture: level, target and the event's fields as text.
441    #[derive(Clone, Default)]
442    struct Caught(std::sync::Arc<parking_lot::Mutex<Vec<(tracing::Level, String, String)>>>);
443
444    #[derive(Default)]
445    struct CaughtFields(String);
446
447    impl tracing::field::Visit for CaughtFields {
448        fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
449            use std::fmt::Write as _;
450            if !self.0.is_empty() {
451                self.0.push(' ');
452            }
453            let _ = write!(&mut self.0, "{}={value:?}", field.name());
454        }
455    }
456
457    impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for Caught {
458        fn on_event(
459            &self,
460            event: &tracing::Event<'_>,
461            _ctx: tracing_subscriber::layer::Context<'_, S>,
462        ) {
463            let mut fields = CaughtFields::default();
464            event.record(&mut fields);
465            self.0.lock().push((
466                *event.metadata().level(),
467                event.metadata().target().to_owned(),
468                fields.0,
469            ));
470        }
471    }
472
473    /// **A refused door leaves its trace.** The call record is what makes a
474    /// refusal visible after the fact, so the lead gate runs below it.
475    #[tokio::test]
476    async fn a_refused_end_host_call_is_recorded() {
477        use tracing_subscriber::layer::SubscriberExt as _;
478        let mock = Arc::new(MockBrowserFacade::new());
479        let tool = BrowserTool {
480            spec: spec_named("browser_end_host"),
481            facade: Arc::clone(&mock) as Arc<dyn BrowserFacade>,
482            slot: seat(),
483        };
484        let caught = Caught::default();
485        let subscriber = tracing_subscriber::registry().with(caught.clone());
486        let _guard = tracing::subscriber::set_default(subscriber);
487
488        let out = tool.call(ToolInput { value: json!({}) }).await;
489
490        assert!(out.is_error, "the worker is refused");
491        let records = caught.0.lock();
492        assert!(
493            records.iter().any(|(level, target, fields)| {
494                *level == tracing::Level::DEBUG
495                    && target == "forge_workspace::browser"
496                    && fields.contains("event_name=\"browser_call\"")
497                    && fields.contains("ok=false")
498            }),
499            "the refusal is recorded: {records:?}",
500        );
501    }
502
503    /// An image the host answered with crosses as an MCP image block, bytes
504    /// and all: a screenshot is a result, not a path a client has to go and
505    /// open.
506    #[tokio::test]
507    async fn an_image_answer_crosses_as_an_image_block() {
508        let mock = Arc::new(MockBrowserFacade::new());
509        *mock.answer.lock() = Ok(vec![
510            BrowserPart::Text { text: "captured".to_owned() },
511            BrowserPart::Image { mime_type: "image/png".to_owned(), bytes: vec![1, 2] },
512        ]);
513        let tool = BrowserTool {
514            spec: spec_named("browser_take_screenshot"),
515            facade: Arc::clone(&mock) as Arc<dyn BrowserFacade>,
516            slot: seat(),
517        };
518
519        let out = tool.call(ToolInput { value: json!({}) }).await;
520
521        assert_eq!(
522            out.blocks,
523            vec![
524                ToolOutputBlock::Text { text: "captured".to_owned() },
525                ToolOutputBlock::Image { mime_type: "image/png".to_owned(), data: vec![1, 2] },
526            ],
527            "the parts cross in order, the image's bytes with them",
528        );
529    }
530
531    /// **The whole seam, from a tool call to the fleet the server installs.**
532    /// A tool driven through the built server is answered by the host
533    /// installed on the workspace, so a facade reading a fleet of its own
534    /// fails here rather than shipping as every browser tool answering the
535    /// no-host refusal while a fleet runs beside it.
536    #[tokio::test]
537    async fn a_tool_driven_through_the_server_reaches_the_installed_host() {
538        use crate::mcp::browser::facade::ProdBrowserFacade;
539        use crate::mcp::cron::facade::MockCronFacade;
540        use crate::mcp::gotify::facade::MockGotifyFacade;
541        use crate::mcp::peers::facade::MockWorkspaceFacade;
542        use crate::mcp::review::facade::MockReviewFacade;
543        use crate::mcp::slack::facade::MockSlackFacade;
544        use crate::mcp::tasks::facade::MockTasksFacade;
545        use crate::mcp::workers::facade::MockWorkerFacade;
546        use tokio::sync::mpsc;
547
548        let (workspace, _updates) = crate::workspace::Workspace::testing_stub();
549        let (calls, mut heard) = mpsc::unbounded_channel();
550        workspace
551            .install_browser_host(std::sync::Arc::new(crate::browser::testing::StubHost { calls }));
552
553        let server = crate::mcp::build_forge_server(
554            crate::mcp::ForgeServerFacades {
555                workspace: MockWorkspaceFacade::new().into_arc(),
556                worker: MockWorkerFacade::new().into_arc(),
557                // The exact composition the spawn uses.
558                browser: ProdBrowserFacade::from_workspace(&workspace),
559                terminal: crate::mcp::terminal::facade::MockTerminalFacade::new().into_arc(),
560                documents: crate::mcp::documents::facade::ProdDocumentFacade::from_workspace(
561                    &workspace,
562                ),
563                review: MockReviewFacade::new().into_arc(),
564                cron: MockCronFacade::new().into_arc(),
565                gotify: MockGotifyFacade::new().into_arc(),
566                slack: MockSlackFacade::new().into_arc(),
567                settings: crate::mcp::settings::facade::ProdSettingsFacade::from_arc(&workspace),
568                vault: None,
569                tasks: MockTasksFacade::new().into_arc(),
570                systemone: None,
571                voice: workspace.voice_service(),
572            },
573            &crate::mcp::McpFamily::all(),
574            &seat(),
575            crate::mcp::SessionKind::Worker,
576        );
577
578        let answer = server
579            .dispatch(&forge_sdk::mcp::protocol::JsonRpcRequest {
580                jsonrpc: "2.0".to_owned(),
581                id: Some(json!(1)),
582                method: "tools/call".to_owned(),
583                params: Some(json!({ "name": "browser_close", "arguments": {} })),
584            })
585            .await
586            .expect("a tools/call is answered");
587
588        let heard = heard.try_recv().expect(
589            "the call reached the installed host rather than the tool answering without one",
590        );
591        assert_eq!(heard.0, "browser_close", "and names the tool that was called");
592        let encoded = format!("{answer:?}");
593        assert!(
594            encoded.contains("clicked"),
595            "the tool's answer is the host's parts, so the call reached it: {encoded}",
596        );
597        assert!(!encoded.contains("no browser-capable client"), "{encoded}");
598    }
599
600    /// The hand-off is the one tool that does not forward: it parks on the
601    /// person, and what comes back to the model is the ending - Done means
602    /// carry on.
603    #[tokio::test]
604    async fn a_hand_off_parks_on_the_person_and_a_done_answers_it() {
605        let mock = Arc::new(MockBrowserFacade::new());
606        let tool = BrowserTool {
607            spec: spec_named("browser_hand_off"),
608            facade: Arc::clone(&mock) as Arc<dyn BrowserFacade>,
609            slot: seat(),
610        };
611
612        let out = tool
613            .call(ToolInput { value: json!({ "reason": "solve the CAPTCHA", "tab": PAGE_ID }) })
614            .await;
615
616        assert!(!out.is_error, "{:?}", out.blocks);
617        assert!(
618            mock.calls.lock().is_empty(),
619            "the hand-off never reaches the browser: the person is the ask, not the tool",
620        );
621        assert_eq!(
622            mock.hand_offs.lock().as_slice(),
623            [("solve the CAPTCHA".to_owned(), PAGE_ID.to_owned())],
624            "the reason and the tab cross to the park verbatim",
625        );
626        let text = crate::mcp::test_support::block_text(&out.blocks[0]);
627        assert!(text.contains("carry on"), "Done tells the model to carry on: {text}");
628    }
629
630    /// A decline is a tool error with the person's answer in it, so the model
631    /// reads it as "not this way" rather than as a success.
632    #[tokio::test]
633    async fn a_hand_off_decline_is_a_tool_error() {
634        let mock = Arc::new(MockBrowserFacade::new());
635        *mock.hand_off_answer.lock() = Ok(HandOffEnding::NotNow);
636        let tool = BrowserTool {
637            spec: spec_named("browser_hand_off"),
638            facade: Arc::clone(&mock) as Arc<dyn BrowserFacade>,
639            slot: seat(),
640        };
641
642        let out =
643            tool.call(ToolInput { value: json!({ "reason": "sign in", "tab": PAGE_ID }) }).await;
644
645        assert!(out.is_error, "{:?}", out.blocks);
646        let text = crate::mcp::test_support::block_text(&out.blocks[0]);
647        assert!(text.contains("said not now"), "{text}");
648        assert!(text.contains("Ask them again later"), "{text}");
649    }
650
651    /// **A message from the person is a refusal**, not a decline: the agent
652    /// reads it as "the person says something is off", addresses their words,
653    /// and hands the page off again for another answer. It never reads as a
654    /// decline of the page and never tells the agent to carry on.
655    #[tokio::test]
656    async fn a_hand_off_feedback_is_a_refusal() {
657        let mock = Arc::new(MockBrowserFacade::new());
658        *mock.hand_off_answer.lock() = Ok(HandOffEnding::Feedback);
659        let tool = BrowserTool {
660            spec: spec_named("browser_hand_off"),
661            facade: Arc::clone(&mock) as Arc<dyn BrowserFacade>,
662            slot: seat(),
663        };
664
665        let out =
666            tool.call(ToolInput { value: json!({ "reason": "sign in", "tab": PAGE_ID }) }).await;
667
668        assert!(out.is_error, "{:?}", out.blocks);
669        let text = crate::mcp::test_support::block_text(&out.blocks[0]);
670        assert!(text.contains("not approval"), "the answer names the refusal: {text}");
671        assert!(text.contains("message beside this result"), "the words are named: {text}");
672        assert!(text.contains("hand the page off again"), "and the next move: {text}");
673        assert!(!text.contains("carry on"), "and never tells the model to carry on: {text}");
674    }
675
676    /// No tab is the call's own mistake too: the person is asked to look at
677    /// one tab, and a hand-off that named none would raise the wrong page.
678    #[tokio::test]
679    async fn a_hand_off_without_a_tab_is_refused() {
680        let mock = Arc::new(MockBrowserFacade::new());
681        let tool = BrowserTool {
682            spec: spec_named("browser_hand_off"),
683            facade: Arc::clone(&mock) as Arc<dyn BrowserFacade>,
684            slot: seat(),
685        };
686
687        let out = tool.call(ToolInput { value: json!({ "reason": "sign in" }) }).await;
688
689        assert!(out.is_error, "{:?}", out.blocks);
690        let text = crate::mcp::test_support::block_text(&out.blocks[0]);
691        assert!(text.contains("`tab`"), "{text}");
692        assert!(mock.hand_offs.lock().is_empty(), "and nothing was parked");
693    }
694
695    /// **A page id is a string, and the field an upgrade retired is named.**
696    /// A resumed seat's catalogue predates both changes, and a number must not
697    /// be read as a missing argument.
698    #[tokio::test]
699    async fn a_hand_off_requires_a_page_id_string_and_names_the_retired_field() {
700        let mock = Arc::new(MockBrowserFacade::new());
701        let tool = BrowserTool {
702            spec: spec_named("browser_hand_off"),
703            facade: Arc::clone(&mock) as Arc<dyn BrowserFacade>,
704            slot: seat(),
705        };
706
707        let numeric =
708            tool.call(ToolInput { value: json!({ "reason": "sign in", "tab": 2 }) }).await;
709        assert!(numeric.is_error, "{:?}", numeric.blocks);
710        let text = crate::mcp::test_support::block_text(&numeric.blocks[0]);
711        assert!(text.contains("page id string"), "{text}");
712        assert!(text.contains("not a number"), "{text}");
713
714        let retired =
715            tool.call(ToolInput { value: json!({ "reason": "sign in", "index": 2 }) }).await;
716        assert!(retired.is_error, "{:?}", retired.blocks);
717        let text = crate::mcp::test_support::block_text(&retired.blocks[0]);
718        assert!(text.contains("`index` is gone"), "{text}");
719
720        // **A blank string is its own refusal**, not a number by another name.
721        let blank =
722            tool.call(ToolInput { value: json!({ "reason": "sign in", "tab": "   " }) }).await;
723        assert!(blank.is_error, "{:?}", blank.blocks);
724        let text = crate::mcp::test_support::block_text(&blank.blocks[0]);
725        assert!(text.contains("cannot be blank"), "{text}");
726        assert!(mock.hand_offs.lock().is_empty(), "and nothing was parked");
727    }
728
729    /// **A stale seat's numeric tab fails by name, with its recovery.** A
730    /// resumed session's tool list is the one recorded in its transcript,
731    /// where `tab` was a number; under it a model has sent the digit run
732    /// inside a page id (`DD241...` became `241`). The refusal must not take
733    /// the number for a page, and only a fresh session refreshes the list.
734    #[tokio::test]
735    async fn a_stale_numeric_tab_fails_by_name_and_names_the_fresh_session() {
736        let mock = Arc::new(MockBrowserFacade::new());
737        let tool = BrowserTool {
738            spec: spec_named("browser_hand_off"),
739            facade: Arc::clone(&mock) as Arc<dyn BrowserFacade>,
740            slot: seat(),
741        };
742
743        let out = tool.call(ToolInput { value: json!({ "reason": "look", "tab": 241 }) }).await;
744
745        assert!(out.is_error, "{:?}", out.blocks);
746        let text = crate::mcp::test_support::block_text(&out.blocks[0]);
747        assert!(text.contains("page id string"), "{text}");
748        assert!(text.contains("not a number"), "{text}");
749        assert!(text.contains("fresh session in this slot"), "{text}");
750        assert!(mock.hand_offs.lock().is_empty(), "and nothing was parked");
751    }
752
753    /// **A tab that is not a page id is refused, not looked up.** The same
754    /// stale schema drove a model to send a digit run; a value of another
755    /// shape - short, long or non-hex - must fail before any tab is searched
756    /// or parked, and its refusal names the fresh-session recovery.
757    #[tokio::test]
758    async fn a_tab_that_is_not_a_page_id_is_refused_by_name() {
759        let mock = Arc::new(MockBrowserFacade::new());
760        let tool = BrowserTool {
761            spec: spec_named("browser_hand_off"),
762            facade: Arc::clone(&mock) as Arc<dyn BrowserFacade>,
763            slot: seat(),
764        };
765
766        let long = format!("{PAGE_ID}0");
767        let non_hex = format!("ZZ{}", &PAGE_ID[2..]);
768        for tab in ["241", &PAGE_ID[..31], long.as_str(), non_hex.as_str()] {
769            let out = tool.call(ToolInput { value: json!({ "reason": "look", "tab": tab }) }).await;
770            assert!(out.is_error, "{tab}: {:?}", out.blocks);
771            let text = crate::mcp::test_support::block_text(&out.blocks[0]);
772            assert!(text.contains("32 hex"), "{tab}: {text}");
773            assert!(text.contains(tab), "the refused value is named: {tab}: {text}");
774            assert!(text.contains("fresh session in this slot"), "{tab}: {text}");
775        }
776        assert!(mock.hand_offs.lock().is_empty(), "and nothing was parked");
777    }
778
779    /// **A page id is trimmed before it parks.** A listing an agent copied
780    /// with a stray space must not send the person to a page nobody named.
781    #[tokio::test]
782    async fn a_hand_off_trims_its_page_id() {
783        let mock = Arc::new(MockBrowserFacade::new());
784        let tool = BrowserTool {
785            spec: spec_named("browser_hand_off"),
786            facade: Arc::clone(&mock) as Arc<dyn BrowserFacade>,
787            slot: seat(),
788        };
789
790        let out = tool
791            .call(ToolInput {
792                value: json!({ "reason": "sign in", "tab": format!(" {PAGE_ID} ") }),
793            })
794            .await;
795
796        assert!(!out.is_error, "{:?}", out.blocks);
797        assert_eq!(
798            mock.hand_offs.lock().as_slice(),
799            [("sign in".to_owned(), PAGE_ID.to_owned())],
800            "the trimmed id is what parks",
801        );
802    }
803
804    /// A blank reason is no reason: it would park an ask that says nothing to
805    /// the person, so the call is refused before anything reaches them.
806    #[tokio::test]
807    async fn a_hand_off_with_a_blank_reason_is_refused() {
808        let mock = Arc::new(MockBrowserFacade::new());
809        let tool = BrowserTool {
810            spec: spec_named("browser_hand_off"),
811            facade: Arc::clone(&mock) as Arc<dyn BrowserFacade>,
812            slot: seat(),
813        };
814
815        let out = tool.call(ToolInput { value: json!({ "reason": "   ", "tab": PAGE_ID }) }).await;
816
817        assert!(out.is_error, "{:?}", out.blocks);
818        let text = crate::mcp::test_support::block_text(&out.blocks[0]);
819        assert!(text.contains("`reason`"), "{text}");
820        assert!(mock.hand_offs.lock().is_empty(), "and nothing was parked");
821    }
822
823    /// No reason is the call's own mistake, answered before anything parks.
824    #[tokio::test]
825    async fn a_hand_off_without_a_reason_is_refused() {
826        let mock = Arc::new(MockBrowserFacade::new());
827        let tool = BrowserTool {
828            spec: spec_named("browser_hand_off"),
829            facade: Arc::clone(&mock) as Arc<dyn BrowserFacade>,
830            slot: seat(),
831        };
832
833        let out = tool.call(ToolInput { value: json!({}) }).await;
834
835        assert!(out.is_error, "{:?}", out.blocks);
836        let text = crate::mcp::test_support::block_text(&out.blocks[0]);
837        assert!(text.contains("`reason`"), "{text}");
838        assert!(mock.hand_offs.lock().is_empty(), "and nothing was parked");
839    }
840
841    /// A stream nobody can answer on fails closed with the named reason
842    /// rather than parking the session on a prompt no view can draw.
843    #[tokio::test]
844    async fn a_hand_off_that_cannot_be_shown_fails_closed() {
845        let mock = Arc::new(MockBrowserFacade::new());
846        *mock.hand_off_answer.lock() =
847            Err("no attached client can show the browser hand-off".to_owned());
848        let tool = BrowserTool {
849            spec: spec_named("browser_hand_off"),
850            facade: Arc::clone(&mock) as Arc<dyn BrowserFacade>,
851            slot: seat(),
852        };
853
854        let out =
855            tool.call(ToolInput { value: json!({ "reason": "sign in", "tab": PAGE_ID }) }).await;
856
857        assert!(out.is_error, "{:?}", out.blocks);
858        let text = crate::mcp::test_support::block_text(&out.blocks[0]);
859        assert!(text.contains("no attached client"), "{text}");
860    }
861}