From fbff8857f3894ca3bd0f5b0959c3f05d949a37e7 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Tue, 11 Aug 2026 08:03:39 +0800 Subject: [PATCH] feat(mcp): expose the style-guide catalogue to MCP and the CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_style_guide answers "give me a guide matching this" and searches the shipped corpus only, so an agent choosing an asset could neither see what exists nor reach the DESIGN.md files the user imported — the material most worth picking was invisible to MCP entirely. list_style_guides merges both, imports first as the Asset Center paints them, and keeps them apart by id so an import cannot take a shipped guide's place. Passing id returns that one guide with its markdown, which is what closes the loop for imports. No data moved: the canonical registries already live in op-ai-skills, and only the swatch colours and summary line were ever widget-layer concerns. The tool reads the host's imported files, so it is a LocalFilesystem surface and denied online for the same reason list_theme_presets is. --- crates/op-cli/src/cli_export_tests.rs | 26 +++++ crates/op-cli/src/main.rs | 13 +++ crates/op-cli/src/template_cli.rs | 33 ++++++ crates/op-cli/src/usage.txt | 3 + crates/op-host-services/src/mcp_serve.rs | 43 ++++---- .../op-host-services/src/mcp_serve/schemas.rs | 1 + .../op-host-services/src/mcp_serve/tests.rs | 2 +- .../src/mcp_serve/tool_profile.rs | 7 ++ .../src/mcp_serve/tool_profile_tests.rs | 3 + crates/op-mcp/src/lib.rs | 3 +- crates/op-mcp/src/style_guide_tools.rs | 103 +++++++++++++++++- crates/op-mcp/src/style_guide_tools_tests.rs | 60 +++++++++- 12 files changed, 272 insertions(+), 25 deletions(-) diff --git a/crates/op-cli/src/cli_export_tests.rs b/crates/op-cli/src/cli_export_tests.rs index a60ed8c2c..3fc4971db 100644 --- a/crates/op-cli/src/cli_export_tests.rs +++ b/crates/op-cli/src/cli_export_tests.rs @@ -230,3 +230,29 @@ fn templates_filters_are_optional() { Command::Templates { scene: Some(ref scene), .. } if scene == "slides" )); } + +#[test] +fn styles_takes_a_bare_id_or_filters() { + let listing = parse_args(&args(&["styles"])).expect("parse styles"); + assert!(matches!( + listing.command, + Command::Styles { + id: None, + tag: None, + platform: None + } + )); + + let one = parse_args(&args(&["styles", "user:my-brand"])).expect("parse styles id"); + assert!(matches!( + one.command, + Command::Styles { id: Some(ref id), .. } if id == "user:my-brand" + )); + + let filtered = + parse_args(&args(&["styles", "--platform", "slides"])).expect("parse styles filter"); + assert!(matches!( + filtered.command, + Command::Styles { platform: Some(ref platform), .. } if platform == "slides" + )); +} diff --git a/crates/op-cli/src/main.rs b/crates/op-cli/src/main.rs index 5b966fadb..1645d6c3c 100644 --- a/crates/op-cli/src/main.rs +++ b/crates/op-cli/src/main.rs @@ -139,6 +139,13 @@ fn run(args: &[String]) -> Result { Command::UseTemplate { template_id } => { template_cli::run_use_template(target_port, &target_token, &template_id)? } + Command::Styles { id, tag, platform } => template_cli::run_styles( + target_port, + &target_token, + id.as_deref(), + tag.as_deref(), + platform.as_deref(), + )?, Command::Export { item_id, selection: _, @@ -235,6 +242,11 @@ enum Command { UseTemplate { template_id: String, }, + Styles { + id: Option, + tag: Option, + platform: Option, + }, } type Flags = BTreeMap>; @@ -362,6 +374,7 @@ fn command_from_positionals(positionals: &[String], flags: &Flags) -> Result export_cli::map_export_deck(flags), "templates" => template_cli::map_templates(flags), "use-template" => template_cli::map_use_template(flags, positionals), + "styles" => template_cli::map_styles(flags, positionals), "skill:export" => Ok(Command::SkillExport { name: required_pos( positionals, diff --git a/crates/op-cli/src/template_cli.rs b/crates/op-cli/src/template_cli.rs index 91e97acec..912ee5874 100644 --- a/crates/op-cli/src/template_cli.rs +++ b/crates/op-cli/src/template_cli.rs @@ -66,3 +66,36 @@ pub(crate) fn run_use_template( &tool_call_body("use_scene_template", &Value::Object(arguments).to_string()), ) } + +pub(crate) fn map_styles(flags: &Flags, positionals: &[String]) -> Result { + Ok(Command::Styles { + // `positionals[0]` is the command word; a bare id after it selects one + // guide and pulls its markdown down with it. + id: positionals + .get(1) + .cloned() + .or_else(|| flag_value(flags, "id")), + tag: flag_value(flags, "tag"), + platform: flag_value(flags, "platform"), + }) +} + +pub(crate) fn run_styles( + port: u16, + token: &str, + id: Option<&str>, + tag: Option<&str>, + platform: Option<&str>, +) -> Result { + let mut arguments = serde_json::Map::new(); + for (key, value) in [("id", id), ("tag", tag), ("platform", platform)] { + if let Some(value) = value { + arguments.insert(key.into(), Value::String(value.into())); + } + } + post( + port, + token, + &tool_call_body("list_style_guides", &Value::Object(arguments).to_string()), + ) +} diff --git a/crates/op-cli/src/usage.txt b/crates/op-cli/src/usage.txt index b7758076e..e7f05d67b 100644 --- a/crates/op-cli/src/usage.txt +++ b/crates/op-cli/src/usage.txt @@ -34,6 +34,9 @@ COMMON COMMANDS: op use-template start from a scene template: takes over a blank starter page, otherwise appends its boards + op styles [] [--tag T] [--platform P] list style guides: the shipped + corpus plus your imported DESIGN.md + files; a bare id prints its markdown op get [--type T] [--name N] [--id ID] [--depth N] [--parent P] op selection get current selection op insert [--parent P] [--page PAGE] [--post-process] diff --git a/crates/op-host-services/src/mcp_serve.rs b/crates/op-host-services/src/mcp_serve.rs index 97a427662..c5f5ef340 100644 --- a/crates/op-host-services/src/mcp_serve.rs +++ b/crates/op-host-services/src/mcp_serve.rs @@ -37,27 +37,27 @@ use op_mcp::{ get_style_guide_tags_snapshot, get_variables_snapshot, get_viewport_snapshot, group_selected_snapshot, import_svg_snapshot, insert_node_snapshot, instantiate_component_snapshot, lint_document_snapshot, list_components_snapshot, - list_node_kinds_snapshot, list_pages_snapshot, list_theme_presets_snapshot, - list_variables_snapshot, load_theme_preset_snapshot, move_node_snapshot, - nudge_selected_snapshot, open_document_snapshot, paste_clipboard_snapshot, read_nodes_snapshot, - redo_snapshot, remove_node_effect_snapshot, remove_page_snapshot, rename_component_snapshot, - rename_page_snapshot, rename_variable_snapshot, reorder_page_snapshot, - reorder_selected_snapshot, replace_all_matching_properties_snapshot, replace_node_snapshot, - run_stdio_with_applier, save_document_snapshot, save_theme_preset_snapshot, - search_all_unique_properties_snapshot, selection_snapshot, set_active_axis_value_snapshot, - set_active_page_snapshot, set_active_tool_snapshot, set_design_md_snapshot, - set_ellipse_arc_snapshot, set_node_collapsed_snapshot, set_node_corner_radius_snapshot, - set_node_fill_hex_snapshot, set_node_flip_snapshot, set_node_font_size_snapshot, - set_node_font_weight_snapshot, set_node_hidden_snapshot, set_node_locked_snapshot, - set_node_name_snapshot, set_node_rotation_snapshot, set_node_stroke_hex_snapshot, - set_node_stroke_side_width_snapshot, set_node_stroke_width_snapshot, set_node_text_snapshot, - set_selection_set_snapshot, set_selection_snapshot, set_themes_snapshot, - set_variable_boolean_snapshot, set_variable_color_snapshot, set_variable_number_snapshot, - set_variable_string_snapshot, set_variables_snapshot, set_viewport_snapshot, - snapshot_layout_snapshot, spawn_agents_snapshot, toggle_node_selection_snapshot, - tool_search_snapshot, undo_snapshot, ungroup_selected_snapshot, update_node_snapshot, - upsert_component_snapshot, upsert_screen_snapshot, upsert_variables_snapshot, McpTool, - ToolRegistry, + list_node_kinds_snapshot, list_pages_snapshot, list_style_guides_snapshot, + list_theme_presets_snapshot, list_variables_snapshot, load_theme_preset_snapshot, + move_node_snapshot, nudge_selected_snapshot, open_document_snapshot, paste_clipboard_snapshot, + read_nodes_snapshot, redo_snapshot, remove_node_effect_snapshot, remove_page_snapshot, + rename_component_snapshot, rename_page_snapshot, rename_variable_snapshot, + reorder_page_snapshot, reorder_selected_snapshot, replace_all_matching_properties_snapshot, + replace_node_snapshot, run_stdio_with_applier, save_document_snapshot, + save_theme_preset_snapshot, search_all_unique_properties_snapshot, selection_snapshot, + set_active_axis_value_snapshot, set_active_page_snapshot, set_active_tool_snapshot, + set_design_md_snapshot, set_ellipse_arc_snapshot, set_node_collapsed_snapshot, + set_node_corner_radius_snapshot, set_node_fill_hex_snapshot, set_node_flip_snapshot, + set_node_font_size_snapshot, set_node_font_weight_snapshot, set_node_hidden_snapshot, + set_node_locked_snapshot, set_node_name_snapshot, set_node_rotation_snapshot, + set_node_stroke_hex_snapshot, set_node_stroke_side_width_snapshot, + set_node_stroke_width_snapshot, set_node_text_snapshot, set_selection_set_snapshot, + set_selection_snapshot, set_themes_snapshot, set_variable_boolean_snapshot, + set_variable_color_snapshot, set_variable_number_snapshot, set_variable_string_snapshot, + set_variables_snapshot, set_viewport_snapshot, snapshot_layout_snapshot, spawn_agents_snapshot, + toggle_node_selection_snapshot, tool_search_snapshot, undo_snapshot, ungroup_selected_snapshot, + update_node_snapshot, upsert_component_snapshot, upsert_screen_snapshot, + upsert_variables_snapshot, McpTool, ToolRegistry, }; #[cfg(feature = "mcp-debug-tools")] use op_mcp::{ @@ -631,6 +631,7 @@ fn rebuild_registry(doc: &EditorState, requested_tool: Option<&str>) -> ToolRegi register_tool!("export_design_md", export_design_md_snapshot(doc)); register_tool!("get_style_guide_tags", get_style_guide_tags_snapshot()); register_tool!("get_style_guide", get_style_guide_snapshot()); + register_tool!("list_style_guides", list_style_guides_snapshot()); register_tool!("get_guidelines", get_guidelines_snapshot()); // Phase 0: always register spawn_agents (validates + returns request result). // Actual parallel execution is deferred to Phase 3 (Task 3.1). diff --git a/crates/op-host-services/src/mcp_serve/schemas.rs b/crates/op-host-services/src/mcp_serve/schemas.rs index 69b1ed68d..e448a55e3 100644 --- a/crates/op-host-services/src/mcp_serve/schemas.rs +++ b/crates/op-host-services/src/mcp_serve/schemas.rs @@ -24,6 +24,7 @@ pub const TOOL_SCHEMAS: &[&str] = &[ r#"{"name":"export_design_md","description":"Export design.md markdown, falling back to best-effort extraction when none is persisted.","inputSchema":{"type":"object","properties":{"filePath":{"type":"string","description":"Optional target .op file path; omit to use the server document"}}}}"#, r#"{"name":"get_style_guide_tags","description":"Return all available style guide tags for filtering light/dark visual styles.","inputSchema":{"type":"object","properties":{},"additionalProperties":false}}"#, r#"{"name":"get_style_guide","description":"Return a style guide by name or best tag match. Provide tags array/string, name, and optional platform.","inputSchema":{"type":"object","properties":{"tags":{"type":"array","items":{"type":"string"}},"name":{"type":"string"},"platform":{"type":"string","enum":["webapp","mobile","landing-page","slides"]}}}}"#, + r#"{"name":"list_style_guides","description":"List every style guide the editor can offer: the shipped corpus plus the DESIGN.md files the user imported (imports first). Pass id to fetch one guide with its markdown — get_style_guide reaches the shipped corpus only.","inputSchema":{"type":"object","properties":{"id":{"type":"string","description":"Guide id: a corpus guide's name, or user: for an import"},"tag":{"type":"string"},"platform":{"type":"string","enum":["webapp","mobile","landing-page","slides"]}}}}"#, r#"{"name":"get_guidelines","description":"Return OpenPencil product-design guidelines. Default category is guide, which reads topic. category=style resolves an OpenPencil style using flat scalar string args.","inputSchema":{"type":"object","properties":{"category":{"type":"string","enum":["guide","style"],"default":"guide","description":"guide returns the existing topic guideline path; style resolves the style catalog"},"topic":{"type":"string","enum":["web-app","mobile","code-to-design"],"description":"Guide topic for category=guide"},"name":{"type":"string","description":"Style catalog name for category=style"},"colorPalette":{"type":"string","description":"Style palette name for category=style"},"roundness":{"type":"string","description":"Roundness profile for category=style"},"elevation":{"type":"string","description":"Elevation profile for category=style"},"headings":{"type":"string","description":"Heading font family for category=style"},"body":{"type":"string","description":"Body font family for category=style"},"captions":{"type":"string","description":"Caption font family for category=style"},"data":{"type":"string","description":"Data font family for category=style"},"decorativeImagery":{"type":"string","description":"Optional decorative imagery guidance for category=style"}}}}"#, r#"{"name":"spawn_agents","description":"Split a large design task into parallel subtasks. Each config item gives a prompt, the container node(s) to fill, and the styleguide + guideline NAMES to pass to the subagent (subagents cannot search styleguides). Returns the spawned agent ids. Execution runs the subagents in parallel.","inputSchema":{"type":"object","properties":{"config":{"type":"array","items":{"type":"object","properties":{"prompt":{"type":"string"},"containerNodes":{"type":"array","items":{"type":"string"}},"styleguideName":{"type":"string"},"guidelineNames":{"type":"array","items":{"type":"string"}}},"required":["prompt","styleguideName"]}}},"required":["config"]}}"#, r#"{"name":"ToolSearch","description":"Discover tools by keyword or exact selection. Use 'select:Name1,Name2' to load specific tools, or a keyword query to search names+descriptions.","inputSchema":{"type":"object","properties":{"query":{"type":"string"},"max_results":{"type":"integer","minimum":1,"default":5}},"required":["query"]}}"#, diff --git a/crates/op-host-services/src/mcp_serve/tests.rs b/crates/op-host-services/src/mcp_serve/tests.rs index 56bc00cf9..098d97190 100644 --- a/crates/op-host-services/src/mcp_serve/tests.rs +++ b/crates/op-host-services/src/mcp_serve/tests.rs @@ -24,7 +24,7 @@ fn tools_list_response_includes_all_registered_tools() { // TOOL_SCHEMAS without being added to the list below. assert_eq!( TOOL_SCHEMAS.len(), - 126, + 127, "tools/list catalog count must match the registered tools — add the new tool to this test" ); // Production catalog excludes debug tools (we removed the diff --git a/crates/op-host-services/src/mcp_serve/tool_profile.rs b/crates/op-host-services/src/mcp_serve/tool_profile.rs index 12c50f2cc..ba4c32ce6 100644 --- a/crates/op-host-services/src/mcp_serve/tool_profile.rs +++ b/crates/op-host-services/src/mcp_serve/tool_profile.rs @@ -380,6 +380,13 @@ pub const TOOL_PROFILES: &[ToolProfile] = &[ ToolAccess::Read, ToolSurface::LocalFilesystem, ), + // Reads the user's imported DESIGN.md files off the daemon host, not + // just the embedded corpus, so it is not an in-memory surface. + ToolProfile::new( + "list_style_guides", + ToolAccess::Read, + ToolSurface::LocalFilesystem, + ), ToolProfile::new( "list_scene_templates", ToolAccess::Read, diff --git a/crates/op-host-services/src/mcp_serve/tool_profile_tests.rs b/crates/op-host-services/src/mcp_serve/tool_profile_tests.rs index 415dddba8..ee62e2bb8 100644 --- a/crates/op-host-services/src/mcp_serve/tool_profile_tests.rs +++ b/crates/op-host-services/src/mcp_serve/tool_profile_tests.rs @@ -70,6 +70,9 @@ fn the_denied_set_is_exactly_the_reviewed_list() { "import_html_url", "import_svg", "import_web_snapshot", + // Reads the host's imported DESIGN.md files, which carry no tenant + // dimension — denied online for the same reason list_theme_presets is. + "list_style_guides", "list_theme_presets", "load_theme_preset", "open_document", diff --git a/crates/op-mcp/src/lib.rs b/crates/op-mcp/src/lib.rs index 77daa7c87..99fb91664 100644 --- a/crates/op-mcp/src/lib.rs +++ b/crates/op-mcp/src/lib.rs @@ -268,7 +268,8 @@ pub use spawn_agents_tool::{spawn_agents_snapshot, SpawnAgents, SpawnSpec}; pub mod tool_search; pub use editor_state_tool::{get_editor_state_snapshot, GetEditorState}; pub use style_guide_tools::{ - get_style_guide_snapshot, get_style_guide_tags_snapshot, GetStyleGuide, GetStyleGuideTags, + get_style_guide_snapshot, get_style_guide_tags_snapshot, list_style_guides_snapshot, + GetStyleGuide, GetStyleGuideTags, ListStyleGuides, }; pub use style_ops_tools::{ replace_all_matching_properties_snapshot, search_all_unique_properties_snapshot, diff --git a/crates/op-mcp/src/style_guide_tools.rs b/crates/op-mcp/src/style_guide_tools.rs index af6b97ebe..10d825cb8 100644 --- a/crates/op-mcp/src/style_guide_tools.rs +++ b/crates/op-mcp/src/style_guide_tools.rs @@ -3,7 +3,8 @@ use std::collections::BTreeMap; use op_ai_skills::style_guide::{ - select_style_guide, style_guide_registry, Platform, SelectOptions, STYLE_GUIDE_TAGS, + select_style_guide, style_guide_registry, user_style_guides, Platform, SelectOptions, + STYLE_GUIDE_TAGS, }; use super::{McpTool, ToolErrorCode, ToolOutcome}; @@ -79,6 +80,106 @@ impl McpTool for GetStyleGuide { } } +/// The Asset Center's Styles tab, as a tool. +/// +/// `get_style_guide` answers "give me *a* guide matching this", and only ever +/// from the shipped corpus. Neither half serves an agent choosing an asset: it +/// cannot see what exists, and the user's own imported `DESIGN.md` files — +/// the material most worth picking — were invisible to MCP entirely. +/// +/// Imports come first, as they do in the panel: a list where your own material +/// sits below fifty shipped entries is a list you scroll past your own work in. +/// Ids keep the two apart, so an import that names itself after a shipped guide +/// cannot quietly take its place. +/// +/// Passing `id` returns that one entry with its markdown, which is what closes +/// the loop for user guides — `get_style_guide` cannot reach them by name. +pub struct ListStyleGuides; + +impl McpTool for ListStyleGuides { + fn name(&self) -> &str { + "list_style_guides" + } + + fn call(&self, args: &BTreeMap) -> ToolOutcome { + let arg = |key: &str| { + args.get(key) + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + }; + let wanted_id = arg("id"); + let tag_filter = arg("tag"); + let platform_filter = arg("platform"); + + let user_guides = user_style_guides(); + let mut entries: Vec = Vec::new(); + + for user in &user_guides { + entries.push(serde_json::json!({ + "id": user.id, + "name": user.guide.name, + "platform": user.guide.platform.as_str(), + "tags": user.guide.tags, + "isUser": true, + "swatches": user.swatches, + })); + } + for guide in style_guide_registry() { + entries.push(serde_json::json!({ + // A corpus guide is pinned by its bare name; that is its id. + "id": guide.name, + "name": guide.name, + "platform": guide.platform.as_str(), + "tags": guide.tags, + "isUser": false, + })); + } + + if let Some(wanted_id) = wanted_id { + let Some(mut entry) = entries + .into_iter() + .find(|entry| entry["id"].as_str() == Some(wanted_id)) + else { + return ToolOutcome::Err( + ToolErrorCode::InvalidArgument, + format!("unknown style guide id {wanted_id:?}"), + ); + }; + let content = user_guides + .iter() + .find(|user| user.id == wanted_id) + .map(|user| user.guide.content.clone()) + .or_else(|| { + style_guide_registry() + .iter() + .find(|guide| guide.name == wanted_id) + .map(|guide| guide.content.clone()) + }); + if let Some(content) = content { + entry["content"] = serde_json::Value::String(content); + } + return ToolOutcome::OkJson(serde_json::json!({ "guides": [entry] }).to_string()); + } + + entries.retain(|entry| { + let tag_ok = tag_filter.is_none_or(|tag| { + entry["tags"] + .as_array() + .is_some_and(|tags| tags.iter().any(|candidate| candidate == tag)) + }); + let platform_ok = + platform_filter.is_none_or(|platform| entry["platform"].as_str() == Some(platform)); + tag_ok && platform_ok + }); + let count = entries.len(); + ToolOutcome::OkJson(serde_json::json!({ "guides": entries, "count": count }).to_string()) + } +} + +pub fn list_style_guides_snapshot() -> ListStyleGuides { + ListStyleGuides +} + pub fn get_style_guide_tags_snapshot() -> GetStyleGuideTags { GetStyleGuideTags } diff --git a/crates/op-mcp/src/style_guide_tools_tests.rs b/crates/op-mcp/src/style_guide_tools_tests.rs index 852ec6b53..b06a28006 100644 --- a/crates/op-mcp/src/style_guide_tools_tests.rs +++ b/crates/op-mcp/src/style_guide_tools_tests.rs @@ -2,7 +2,10 @@ use std::collections::BTreeMap; -use super::{get_style_guide_snapshot, get_style_guide_tags_snapshot, McpTool, ToolOutcome}; +use super::{ + get_style_guide_snapshot, get_style_guide_tags_snapshot, list_style_guides_snapshot, McpTool, + ToolErrorCode, ToolOutcome, +}; #[test] fn get_style_guide_tags_returns_light_and_dark_vocab() { @@ -40,3 +43,58 @@ fn get_style_guide_finds_specific_guide_by_name() { other => panic!("expected guide ok, got {other:?}"), } } + +#[test] +fn listing_style_guides_covers_the_whole_shipped_corpus() { + let out = match list_style_guides_snapshot().call(&BTreeMap::new()) { + ToolOutcome::OkJson(json) => json, + other => panic!("unexpected outcome: {other:?}"), + }; + let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON"); + let guides = value["guides"].as_array().expect("guides array"); + let corpus = op_ai_skills::style_guide::style_guide_registry().len(); + assert!( + guides.len() >= corpus, + "listing dropped corpus entries: {} < {corpus}", + guides.len() + ); + assert_eq!(value["count"].as_u64(), Some(guides.len() as u64)); + // Every corpus entry is addressable by the id the list hands back. + for guide in op_ai_skills::style_guide::style_guide_registry() { + assert!( + guides + .iter() + .any(|entry| entry["id"].as_str() == Some(guide.name.as_str())), + "{} missing from the listing", + guide.name + ); + } +} + +#[test] +fn requesting_one_style_guide_by_id_carries_its_markdown() { + let first = &op_ai_skills::style_guide::style_guide_registry()[0]; + let mut args = BTreeMap::new(); + args.insert("id".to_string(), first.name.clone()); + let out = match list_style_guides_snapshot().call(&args) { + ToolOutcome::OkJson(json) => json, + other => panic!("unexpected outcome: {other:?}"), + }; + let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON"); + let guides = value["guides"].as_array().expect("guides array"); + assert_eq!(guides.len(), 1); + assert_eq!(guides[0]["content"].as_str(), Some(first.content.as_str())); +} + +#[test] +fn an_unknown_style_guide_id_is_a_named_argument_error() { + let mut args = BTreeMap::new(); + args.insert("id".to_string(), "no-such-guide".to_string()); + match list_style_guides_snapshot().call(&args) { + ToolOutcome::Err(code, message) => { + assert_eq!(code, ToolErrorCode::InvalidArgument); + assert!(message.contains("no-such-guide"), "{message}"); + } + other => panic!("unexpected outcome: {other:?}"), + } +}