Skip to main content

forge_workspace/mcp/
documents.rs

1//! The document hand-off family: one tool a session calls to hand the person
2//! at a client a document - markdown, a PDF or plain text - which opens in
3//! the client's own viewer rather than in the conversation.
4//!
5//! **The tool answers at the reader's decision, like the browser hand-off.**
6//! The call parks on the ask - **open** or **not now** - and the viewer's
7//! **approve** or **feedback** releases it with the decision, the note and
8//! the review the finish filed; a review the reader leaves
9//! is filed under the document's own scope and named in the answer, so the
10//! agent reads it back like any review. A session spawned without the
11//! `review` family still hands off, but its description and answer name the
12//! family it lacks instead of telling it to call a tool it does not have.
13//! `NotNow` ends the call with the reader's decline. A detached client
14//! leaves the call parked, and the ask is re-readable when a client
15//! returns; only the waiter's own drop abandons it.
16//!
17//! **Any-caller and not toggleable, like the browser**: every session may
18//! hand a document out of its own tree, and the read resolves against that
19//! tree or its project's `document_roots` - so there is nothing a project
20//! could withhold. Every state the read can refuse is the call's failure,
21//! with the read's own sentence in it.
22
23use std::sync::Arc;
24
25use forge_primitives::SessionSlot;
26use forge_primitives::document::DocumentHandOffEnding;
27use forge_primitives::review::doc_scope_ref;
28use forge_sdk::mcp::server::McpServerBuilder;
29use forge_sdk::mcp::tool::{Tool, ToolInput, ToolOutput, ToolOutputBlock};
30use serde_json::Value;
31
32use crate::mcp::documents::facade::DocumentFacade;
33
34pub mod facade;
35
36/// Build a standalone `forge` MCP server carrying only the hand-off tool.
37/// Test-only; production shares one `forge` server via
38/// [`crate::mcp::build_forge_server`].
39#[cfg(test)]
40pub fn build_server(
41    facade: Arc<dyn DocumentFacade>,
42    slot: SessionSlot,
43    review_available: bool,
44) -> forge_sdk::mcp::server::McpServer {
45    add_tools(
46        McpServerBuilder::new("forge", env!("CARGO_PKG_VERSION")),
47        facade,
48        slot,
49        review_available,
50    )
51    .build()
52}
53
54/// Attach the hand-off tool to an existing builder, so it shares the `forge`
55/// server name with every other family.
56pub(crate) fn add_tools(
57    builder: McpServerBuilder,
58    facade: Arc<dyn DocumentFacade>,
59    slot: SessionSlot,
60    review_available: bool,
61) -> McpServerBuilder {
62    builder.tool(HandOffTool { facade, slot, review_available })
63}
64
65/// What the model reads about the tool before calling it: what it hands
66/// over, when `reviewable` earns its place, and which moment this answers.
67const DESCRIPTION: &str = "\
68Hand the reader a document from this session's working tree, or from one of \
69the project's document roots, opened in their own viewer. This tool is for \
70documents - Markdown, PDF, plain text; a visual artifact - an HTML mockup, \
71a PNG - goes to the browser instead: navigate to it and hand off that tab \
72with `browser_hand_off`, never this tool. Use it when a \
73document is the thing the reader should see - a plan, a report, a spec - \
74rather than part of the conversation; a document the reader is meant to \
75review goes here with `reviewable: true`, never as a path or link in \
76chat. `path` is relative to the working directory, or absolute under \
77one of the project's `document_roots` in `forge.toml`. Set `reviewable` \
78when you want review comments back: the reader opens it, comments on \
79lines and answers, and their review then arrives filed under the \
80document's own scope. A PDF always opens read-only. **The answer is \
81approve or send feedback**: feedback means revise the document, hand it \
82off again, and keep handing it off until the reader approves; approval \
83is the one signal to proceed, and neither may be read as the other; the \
84one feedback box reaches you with either \
85answer, though approve may leave it empty and send feedback may not. \
86The reader is asked to open it or not, and **this call waits for them**: \
87it answers when they approve, send feedback or say not now.";
88
89/// What a session spawned without `review` reads instead, before it calls:
90/// the hand-off works and the reader may still review, but the review is
91/// the lead's to read, so the plain description would promise it back. The
92/// paragraphs it shares with `DESCRIPTION` change in both.
93const DESCRIPTION_WITHOUT_REVIEW: &str = "\
94Hand the reader a document from this session's working tree, or from one of \
95the project's document roots, opened in their own viewer. This tool is for \
96documents - Markdown, PDF, plain text; a visual artifact - an HTML mockup, \
97a PNG - goes to the browser instead: navigate to it and hand off that tab \
98with `browser_hand_off`, never this tool. Use it when a \
99document is the thing the reader should see - a plan, a report, a spec - \
100rather than part of the conversation; a document the reader is meant to \
101review goes here with `reviewable: true`, never as a path or link in \
102chat. `path` is relative to the working directory, or absolute under \
103one of the project's `document_roots` in `forge.toml`. Set `reviewable` \
104to ask the reader for a review of it: they open it, comment on lines \
105and answer, and their review is filed under the document's own scope. \
106This session was spawned without the `review` MCP family, so you cannot \
107read it back; the answer names the review's id and scope, which your lead \
108reads with review__get. A PDF always opens read-only. **The answer is \
109approve or send feedback**: feedback means revise the document, hand it \
110off again, and keep handing it off until the reader approves; approval \
111is the one signal to proceed, and neither may be read as the other; the \
112one feedback box reaches you with either \
113answer, though approve may leave it empty and send feedback may not. \
114The reader is asked to open it or not, and **this call waits for them**: \
115it answers when they approve, send feedback or say not now.";
116
117/// The one tool: the hand-off itself.
118pub(crate) struct HandOffTool {
119    facade: Arc<dyn DocumentFacade>,
120    slot: SessionSlot,
121    /// Whether this session was built with the `review` family, which
122    /// decides whether its answer can send it to `review__get`.
123    review_available: bool,
124}
125
126#[async_trait::async_trait]
127impl Tool for HandOffTool {
128    fn name(&self) -> &'static str {
129        // The family segment is the family: every forge tool is
130        // `mcp__forge__<family>__<verb>`, and the client reads the card and
131        // glyph out of that segment - a tool without one draws as the
132        // generic MCP call (Ved, the look: "every tool has its own glyph").
133        "documents__hand_off"
134    }
135
136    fn description(&self) -> &'static str {
137        if self.review_available { DESCRIPTION } else { DESCRIPTION_WITHOUT_REVIEW }
138    }
139
140    fn input_schema(&self) -> Value {
141        serde_json::json!({
142            "type": "object",
143            "properties": {
144                "path": {
145                    "type": "string",
146                    "description": "The document's path, relative to the session's working \
147                                    directory, or absolute under one of the project's \
148                                    `document_roots` in `forge.toml`.",
149                },
150                "reviewable": {
151                    "type": "boolean",
152                    "description": "Ask the reader for a review of this document. A document a \
153                                    client can read offers its review either way; a PDF always \
154                                    opens read-only.",
155                },
156            },
157            "required": ["path"],
158            "additionalProperties": false,
159        })
160    }
161
162    async fn call(&self, input: ToolInput) -> ToolOutput {
163        let started = std::time::Instant::now();
164        let outcome = self.hand_off(input.value).await;
165        // The record of calls: what was handed, for whom, and how it ended.
166        // `debug` because a hand-off is the session's own work rather than a
167        // problem forge has.
168        tracing::debug!(
169            target: "forge_workspace::documents",
170            event_name = "document_hand_off",
171            slot = %self.slot.display(),
172            ok = outcome.is_ok(),
173            ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
174            "a document hand-off returned",
175        );
176        match outcome {
177            Ok(text) => {
178                ToolOutput { blocks: vec![ToolOutputBlock::Text { text }], is_error: false }
179            }
180            Err(why) => ToolOutput::error(why),
181        }
182    }
183}
184
185impl HandOffTool {
186    async fn hand_off(&self, args: Value) -> Result<String, String> {
187        let Some(path) = args.get("path").and_then(Value::as_str) else {
188            return Err("documents__hand_off needs `path`: the document's path, relative to this \
189                 session's working directory or absolute under one of the project's document_roots"
190                .to_owned());
191        };
192        let reviewable = match args.get("reviewable") {
193            None | Some(Value::Null) => false,
194            Some(Value::Bool(flag)) => *flag,
195            Some(other) => return Err(format!("`reviewable` is true or false, not {other}")),
196        };
197        let (resolved, ending) = self.facade.hand_off(&self.slot, path, reviewable).await?;
198        Ok(match ending {
199            DocumentHandOffEnding::Approve { review_id, note } => {
200                let opened = format!(
201                    "The reader approved {resolved}. Approval is the one signal to proceed."
202                );
203                format!(
204                    "{opened}{}{}",
205                    Self::note_line(note.as_deref()),
206                    Self::review_line(self.review_available, &resolved, review_id.as_deref())
207                )
208            }
209            DocumentHandOffEnding::Feedback { review_id, note } => {
210                let refused = format!(
211                    "The reader sent feedback at {resolved}. This is not approval: \
212                     revise the document and hand it off again, and keep handing it \
213                     off until the reader approves."
214                );
215                return Err(format!(
216                    "{refused}{}{}",
217                    Self::note_line(note.as_deref()),
218                    Self::review_line(self.review_available, &resolved, review_id.as_deref())
219                ));
220            }
221            DocumentHandOffEnding::NotNow => {
222                return Err("the reader said not now, so nothing opened".to_owned());
223            }
224            DocumentHandOffEnding::Abandoned => {
225                return Err("The hand-off ended without an answer.".to_owned());
226            }
227        })
228    }
229
230    /// The reader's own words, carried verbatim and said to be theirs.
231    fn note_line(note: Option<&str>) -> String {
232        match note {
233            Some(note) => format!(" Their note: {note}"),
234            None => String::new(),
235        }
236    }
237
238    /// The review a finish filed, named the way this session can act on it:
239    /// the id and scope plus the call that reads it back, or - for a
240    /// session spawned without the family - the lead it hands the scope to.
241    fn review_line(review_available: bool, resolved: &str, review_id: Option<&str>) -> String {
242        let Some(review_id) = review_id else {
243            return String::new();
244        };
245        let scope = doc_scope_ref(resolved);
246        if review_available {
247            format!(
248                " Their comments are filed as the review {review_id} under the document's own \
249                 scope, {scope} - read them back with review__get and pass that scope."
250            )
251        } else {
252            format!(
253                " Their comments are filed as the review {review_id} under the document's own \
254                 scope, {scope}. This session was spawned without the `review` MCP family, so \
255                 you cannot read them back - hand your lead the review id and scope above, \
256                 which it reads with review__get."
257            )
258        }
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use std::sync::Arc;
265
266    use forge_primitives::SessionSlot;
267    use serde_json::json;
268
269    use super::*;
270    use crate::mcp::documents::facade::{DocumentFacade, MockDocumentFacade};
271
272    fn seat() -> SessionSlot {
273        SessionSlot::from_str_for_test("caller")
274    }
275
276    fn tool(facade: Arc<dyn DocumentFacade>) -> HandOffTool {
277        tool_with_review(facade, true)
278    }
279
280    fn tool_with_review(facade: Arc<dyn DocumentFacade>, review_available: bool) -> HandOffTool {
281        HandOffTool { facade, slot: seat(), review_available }
282    }
283
284    /// The whole call: the path and the reviewable flag cross to the
285    /// hand-off for the caller's own seat, and the answer carries the
286    /// resolved path and the ending the park released on.
287    #[tokio::test]
288    async fn a_hand_off_carries_the_path_and_the_flag_for_the_callers_seat() {
289        let mock = Arc::new(MockDocumentFacade::new());
290        *mock.answer.lock() = Ok((
291            "/proj/docs/readme.md".to_owned(),
292            DocumentHandOffEnding::Approve { review_id: None, note: None },
293        ));
294
295        let out = tool(Arc::clone(&mock) as Arc<dyn DocumentFacade>)
296            .call(ToolInput { value: json!({ "path": "docs/readme.md", "reviewable": true }) })
297            .await;
298
299        assert!(!out.is_error, "{:?}", out.blocks);
300        assert_eq!(
301            mock.calls.lock().as_slice(),
302            [("docs/readme.md".to_owned(), true)],
303            "the path and the flag cross verbatim",
304        );
305        assert_eq!(
306            mock.seats.lock().as_slice(),
307            [seat()],
308            "and for the seat that called, so the path cannot resolve against another tree",
309        );
310        let text = crate::mcp::test_support::block_text(&out.blocks[0]);
311        assert!(text.contains("/proj/docs/readme.md"), "the answer carries the resolved path");
312    }
313
314    /// A refused read is the call's failure, with the read's own sentence in
315    /// it - the same one a viewer draws.
316    #[tokio::test]
317    async fn a_refused_read_is_a_tool_error_with_the_reads_reason() {
318        let mock = Arc::new(MockDocumentFacade::new());
319        *mock.answer.lock() =
320            Err("the path resolves outside the session's tree: ../../etc/passwd".to_owned());
321
322        let out = tool(Arc::clone(&mock) as Arc<dyn DocumentFacade>)
323            .call(ToolInput { value: json!({ "path": "../../etc/passwd" }) })
324            .await;
325
326        assert!(out.is_error, "{:?}", out.blocks);
327        let text = crate::mcp::test_support::block_text(&out.blocks[0]);
328        assert!(text.contains("outside the session's tree"), "{text}");
329    }
330
331    /// A finish that filed a review names the scope it landed under and the
332    /// review's own id, so the agent reads it back without a failed call
333    /// first; a finish with nothing commented names neither.
334    #[tokio::test]
335    async fn a_finished_hand_offs_answer_names_the_review_and_its_scope() {
336        let mock = Arc::new(MockDocumentFacade::new());
337        *mock.answer.lock() = Ok((
338            "/proj/docs/readme.md".to_owned(),
339            DocumentHandOffEnding::Approve { review_id: Some("r-7".to_owned()), note: None },
340        ));
341        let tool = tool(Arc::clone(&mock) as Arc<dyn DocumentFacade>);
342
343        let with_review = tool.call(ToolInput { value: json!({ "path": "docs/readme.md" }) }).await;
344        assert!(!with_review.is_error, "{:?}", with_review.blocks);
345        let text = crate::mcp::test_support::block_text(&with_review.blocks[0]);
346        assert!(text.contains("r-7"), "the answer names the review: {text}");
347        assert!(text.contains("doc:/proj/docs/readme.md"), "and the scope: {text}");
348        assert!(
349            text.contains("read them back with review__get and pass that scope"),
350            "and the call that reads them back, for a session that has it: {text}",
351        );
352
353        *mock.answer.lock() = Ok((
354            "/proj/docs/readme.md".to_owned(),
355            DocumentHandOffEnding::Approve { review_id: None, note: None },
356        ));
357        let without = tool.call(ToolInput { value: json!({ "path": "docs/readme.md" }) }).await;
358        assert!(!without.is_error, "{:?}", without.blocks);
359        let text = crate::mcp::test_support::block_text(&without.blocks[0]);
360        assert!(!text.contains("doc:"), "nothing commented, so no scope to name: {text}");
361    }
362
363    /// **Approve and feedback are different answers, and the note rides
364    /// both verbatim.** Approval is the one signal an agent may act on;
365    /// feedback says the opposite in its own words and keeps the reader's
366    /// note exactly as they wrote it.
367    #[tokio::test]
368    async fn approve_and_feedback_carry_the_note_and_never_read_as_each_other() {
369        let mock = Arc::new(MockDocumentFacade::new());
370        let tool = tool(Arc::clone(&mock) as Arc<dyn DocumentFacade>);
371
372        *mock.answer.lock() = Ok((
373            "/proj/docs/readme.md".to_owned(),
374            DocumentHandOffEnding::Approve {
375                review_id: Some("r-7".to_owned()),
376                note: Some("ship it".to_owned()),
377            },
378        ));
379        let approved = tool
380            .call(ToolInput { value: json!({ "path": "docs/readme.md", "reviewable": true }) })
381            .await;
382        assert!(!approved.is_error, "an approval is a success: {:?}", approved.blocks);
383        let text = crate::mcp::test_support::block_text(&approved.blocks[0]);
384        assert!(text.contains("approved"), "the answer says what happened: {text}");
385        assert!(text.contains("ship it"), "and carries the note verbatim: {text}");
386        assert!(text.contains("read them back with review__get"), "and the review: {text}");
387
388        *mock.answer.lock() = Ok((
389            "/proj/docs/readme.md".to_owned(),
390            DocumentHandOffEnding::Feedback {
391                review_id: Some("r-8".to_owned()),
392                note: Some("the table is wrong".to_owned()),
393            },
394        ));
395        let refused = tool
396            .call(ToolInput { value: json!({ "path": "docs/readme.md", "reviewable": true }) })
397            .await;
398        assert!(refused.is_error, "feedback is not a hand-off success: {:?}", refused.blocks);
399        let text = crate::mcp::test_support::block_text(&refused.blocks[0]);
400        assert!(text.contains("sent feedback"), "the reader's answer is named: {text}");
401        assert!(text.contains("the table is wrong"), "the note reaches the agent: {text}");
402        assert!(
403            text.contains("revise the document and hand it off again"),
404            "and the loop's next step is stated: {text}",
405        );
406        assert!(
407            text.contains("keep handing it off until the reader approves"),
408            "and the loop is until approval: {text}",
409        );
410        assert!(text.contains("not approval"), "never read as done: {text}");
411    }
412
413    /// A session spawned without the `review` family can hand a reviewable
414    /// document off, but it cannot read the review back. Its answer names
415    /// the family it lacks and routes the scope to its lead instead of
416    /// sending it to a `review__get` it does not have.
417    #[tokio::test]
418    async fn a_hand_off_from_a_session_without_review_names_the_family_it_lacks() {
419        let mock = Arc::new(MockDocumentFacade::new());
420        let tool = tool_with_review(Arc::clone(&mock) as Arc<dyn DocumentFacade>, false);
421
422        *mock.answer.lock() = Ok((
423            "/proj/docs/readme.md".to_owned(),
424            DocumentHandOffEnding::Approve { review_id: Some("r-7".to_owned()), note: None },
425        ));
426        let out = tool
427            .call(ToolInput { value: json!({ "path": "docs/readme.md", "reviewable": true }) })
428            .await;
429        assert!(!out.is_error, "{:?}", out.blocks);
430        let text = crate::mcp::test_support::block_text(&out.blocks[0]);
431        assert!(text.contains("r-7"), "the review is still named: {text}");
432        assert!(
433            text.contains("spawned without the `review` MCP family"),
434            "the answer names the family the session lacks: {text}",
435        );
436        assert!(text.contains("your lead"), "and routes the scope to the lead: {text}");
437        assert!(
438            !text.contains("read them back with review__get and pass that scope"),
439            "it must not send a session without review to read them itself: {text}",
440        );
441    }
442
443    /// The description is the surface read before the call, so a session
444    /// spawned without `review` learns the review will not come back to it
445    /// there, not only once the park releases.
446    #[test]
447    fn the_description_names_the_review_gap_before_the_call() {
448        let without = tool_with_review(MockDocumentFacade::new().into_arc(), false);
449        assert!(
450            without.description().contains("spawned without the `review` MCP family"),
451            "the pre-call text names the family this session lacks: {}",
452            without.description(),
453        );
454        let with = tool(MockDocumentFacade::new().into_arc());
455        assert!(
456            !with.description().contains("spawned without the `review` MCP family"),
457            "and a session that has it is not told otherwise: {}",
458            with.description(),
459        );
460    }
461
462    /// The reader's decline and an abandoned hand-off are the call's
463    /// failures, each with its own sentence - the agent must not read either
464    /// as done.
465    #[tokio::test]
466    async fn a_declined_or_abandoned_hand_off_fails_with_its_reason() {
467        let mock = Arc::new(MockDocumentFacade::new());
468        let tool = tool(Arc::clone(&mock) as Arc<dyn DocumentFacade>);
469
470        *mock.answer.lock() =
471            Ok(("/proj/docs/readme.md".to_owned(), DocumentHandOffEnding::NotNow));
472        let out = tool.call(ToolInput { value: json!({ "path": "docs/readme.md" }) }).await;
473        assert!(out.is_error, "a decline is not a success: {:?}", out.blocks);
474        let text = crate::mcp::test_support::block_text(&out.blocks[0]);
475        assert!(text.contains("not now"), "{text}");
476
477        *mock.answer.lock() =
478            Ok(("/proj/docs/readme.md".to_owned(), DocumentHandOffEnding::Abandoned));
479        let out = tool.call(ToolInput { value: json!({ "path": "docs/readme.md" }) }).await;
480        assert!(out.is_error, "an abandoned hand-off is not a success: {:?}", out.blocks);
481        let text = crate::mcp::test_support::block_text(&out.blocks[0]);
482        assert_eq!(text, "The hand-off ended without an answer.", "{text}");
483    }
484
485    /// `reviewable` is off unless asked for: everything the agent did not
486    /// flag opens read-only.
487    #[tokio::test]
488    async fn reviewable_is_off_unless_asked_for() {
489        let mock = Arc::new(MockDocumentFacade::new());
490
491        let out = tool(Arc::clone(&mock) as Arc<dyn DocumentFacade>)
492            .call(ToolInput { value: json!({ "path": "notes.md" }) })
493            .await;
494
495        assert!(!out.is_error, "{:?}", out.blocks);
496        assert_eq!(
497            mock.calls.lock().as_slice(),
498            [("notes.md".to_owned(), false)],
499            "an absent flag reads as read-only",
500        );
501    }
502
503    /// No path is the call's own mistake, answered before anything reads.
504    #[tokio::test]
505    async fn a_hand_off_without_a_path_is_refused() {
506        let mock = Arc::new(MockDocumentFacade::new());
507
508        let out = tool(Arc::clone(&mock) as Arc<dyn DocumentFacade>)
509            .call(ToolInput { value: json!({}) })
510            .await;
511
512        assert!(out.is_error, "{:?}", out.blocks);
513        let text = crate::mcp::test_support::block_text(&out.blocks[0]);
514        assert!(text.contains("`path`"), "{text}");
515        assert!(mock.calls.lock().is_empty(), "and nothing read");
516    }
517
518    /// The description is read at the moment a caller reaches for the tool,
519    /// so the delivery rule lives there too: a document the reader is meant
520    /// to review goes through the hand-off, never as a path or link in
521    /// chat.
522    #[test]
523    fn the_description_sends_a_review_document_through_the_hand_off() {
524        for review_available in [true, false] {
525            let tool = tool_with_review(MockDocumentFacade::new().into_arc(), review_available);
526            assert!(
527                tool.description().contains(
528                    "a document the reader is meant to review goes here with `reviewable: true`, \
529                     never as a path or link in chat"
530                ),
531                "the description carries the delivery rule (review_available: {review_available}): \
532                 {}",
533                tool.description(),
534            );
535        }
536    }
537
538    /// **A visual artifact is not a document.** The description is read
539    /// before the call, and an HTML mockup or a PNG pushed through it opens
540    /// as a file the reader cannot use; both descriptions route those to the
541    /// browser's tab hand-off instead.
542    #[test]
543    fn both_descriptions_route_visual_artifacts_to_the_browser() {
544        for review_available in [true, false] {
545            let tool = tool_with_review(MockDocumentFacade::new().into_arc(), review_available);
546            let description = tool.description();
547            for phrase in [
548                "a visual artifact - an HTML mockup, a PNG - goes to the browser instead",
549                "hand off that tab with `browser_hand_off`, never this tool",
550            ] {
551                assert!(
552                    description.contains(phrase),
553                    "the routing rule is stated (review_available: {review_available}): \
554                     {description}",
555                );
556            }
557        }
558    }
559
560    /// **The loop is stated before the call, in both descriptions.** A
561    /// session reads the paragraph at the moment it reaches for the tool:
562    /// the two outcomes, feedback's next step and approval's exclusivity,
563    /// and the note that rides them. The conduct block pins the same
564    /// contract for a session that never opens the tool.
565    #[test]
566    fn both_descriptions_state_the_hand_offs_loop() {
567        for review_available in [true, false] {
568            let tool = tool_with_review(MockDocumentFacade::new().into_arc(), review_available);
569            let description = tool.description();
570            assert!(
571                description.contains("The answer is approve or send feedback"),
572                "the two outcomes are named (review_available: {review_available}): {description}",
573            );
574            assert!(
575                description.contains(
576                    "feedback means revise the document, hand it off again, and keep handing it \
577                     off until the reader approves; approval is the one signal to proceed, and \
578                     neither may be read as the other"
579                ),
580                "feedback's next step and approval's exclusivity are stated \
581                 (review_available: {review_available}): {description}",
582            );
583            assert!(
584                description.contains(
585                    "the one feedback box reaches you with either answer, though approve may \
586                     leave it empty and send feedback may not"
587                ),
588                "the box's delivery is stated (review_available: {review_available}): {description}",
589            );
590        }
591    }
592
593    /// The family registers exactly one tool, under the name a prompt calls
594    /// it by.
595    #[test]
596    fn the_family_registers_one_tool_named_documents_hand_off() {
597        let server = build_server(MockDocumentFacade::new().into_arc(), seat(), true);
598        let debug = format!("{server:?}");
599        let (_, tools) = debug.split_once("tools: [").expect("debug lists the tool names");
600        let (tools, _) = tools.split_once(']').expect("the tool list is closed");
601        let names: Vec<&str> = tools
602            .split(", ")
603            .map(|name| name.trim_matches('"'))
604            .filter(|n| !n.is_empty())
605            .collect();
606        assert_eq!(names, ["documents__hand_off"]);
607    }
608}