diff --git a/AGENTS.md b/AGENTS.md index 3781d50..03c6484 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,6 +96,7 @@ External docs: https://clig.dev/llms.txt -> guide you can consult to write bette - `CallToolResponse` is now proto-backed too. `src/main.rs` pretty-prints the typed response envelope directly. - The CLI is dynamic at three levels: ListExtensions determines which top-level extensions exist, ListTools determines which tool subcommands exist under each extension, and each tool's schema determines its flags/options. - We take a hybrid static/dynamic approach: - - **Top-level extensions are static.** `extensions.yaml` is generated from two sources (kGoose ListExtensionsGrpcAction + G2 web app OAuth config for late-init extensions like notion, asana), then manually curated. Run `just update-extensions-catalog` to regenerate. This lets all known extensions appear in `--help` even if the user hasn't connected them yet. - - **Extension subcommands are fully dynamic.** ListTools and CallTool hit the live kgoose API. If the user hasn't connected an extension, `--help` for that extension will fail with a "not connected" error pointing to G2 Connections. The static catalog is used only to produce helpful error messages (distinguishing "unknown extension" from "known but not connected"). + - **`bl tools` top-level extensions are live.** Root help and `--describe-commands` call ListExtensions with the selected backend and caller authentication. Configured extensions with `auth_satisfied_for_caller = false` are marked `[not connected]`; they remain visible in the list. + - **`sq agent-tools` top-level extensions are static.** `extensions.yaml` is generated from two sources (kGoose ListExtensionsGrpcAction + G2 web app OAuth config for late-init extensions like notion, asana), then manually curated. Run `just update-extensions-catalog` to regenerate. This lets all known extensions appear in `--help` even if the user hasn't connected them yet. + - **Extension subcommands are fully dynamic.** ListTools and CallTool hit the live kgoose API. If the user hasn't connected an extension, `--help` for that extension will fail with a "not connected" error pointing to G2 Connections. `bl tools` loads the live ListExtensions set only when ListTools fails, to distinguish "unknown extension" from "configured but not connected"; it never reads the static catalog. `sq agent-tools` uses the static catalog for that error classification. - **`--describe-commands` leaf node is the extension subcommand.** We don't return nested tool commands under an extension, so `sq` forwards args to the extension subcommand directly. diff --git a/README.md b/README.md index 09b6c62..7a0a51c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ `bl` is the command-line interface for BuilderLab. It provides authenticated access to BuilderLab skills, agents, workspaces, and apps, along with the -`bl tools` command for discovering connected tool extensions. +`bl tools` command for discovering configured tool extensions. The CLI is written in Rust and is built from this repository. Run `bl --help` to see the current command surface: @@ -57,6 +57,17 @@ The CLI can also configure the organization used for service routing: ./target/debug/bl config get org ``` +`bl tools` (or `bl tools --help`) fetches the configured extensions from the +selected KGoose backend. Extensions whose credentials are missing for the caller +appear with `[not connected]` beside their description. The same live list is +used by `bl tools --describe-commands`. Listing requires backend access and +authentication, just like inspecting or calling an extension's tools. +When inspecting an extension fails, `bl tools` also uses the live extension list +to distinguish an unknown name from a configured extension that is not connected. + +The separately packaged `sq agent-tools` command uses the embedded +`extensions.yaml` catalog for its top-level help and command discovery. + Use `--json` for machine-readable output and `--verbose` for request diagnostics. Do not include credentials or other sensitive data when sharing verbose output. diff --git a/src/lib.rs b/src/lib.rs index ba17c70..268733d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,8 +22,8 @@ use cli::{ TOOLS_COMMAND_NAME, }; use runtime::{ - load_extension, load_extensions, LoadedExtension, ParameterKind, RuntimeTool, ScalarKind, - ToolParameter, + load_extension, load_extensions, load_extensions_with_auth_status, LoadedExtension, + ParameterKind, RuntimeTool, ScalarKind, ToolParameter, }; use serde::Serialize; use serde_json::{Map, Value}; @@ -244,7 +244,7 @@ fn run_tools_cli(argv0: &str, raw_args: &[String], config: ToolsCliConfig) -> Re } if bootstrap.describe_commands { - if !bootstrap.command_tokens.is_empty() { + if config.builderlab_mode || !bootstrap.command_tokens.is_empty() { apply_builderlab_mode_if_needed(&mut kgoose_config, config.builderlab_mode)?; } let description = @@ -292,15 +292,20 @@ fn run_tools_cli(argv0: &str, raw_args: &[String], config: ToolsCliConfig) -> Re selected_extension_name.as_deref() { apply_builderlab_mode_if_needed(&mut kgoose_config, config.builderlab_mode)?; - let known_extensions = load_extensions_catalog()?; - let loaded = load_extension(&client, &kgoose_config, extension_name, &known_extensions)?; + let loaded = load_tools_extension( + &client, + &kgoose_config, + extension_name, + config.builderlab_mode, + )?; let extensions = vec![runtime::ExtensionSummary { name: loaded.name.clone(), about: loaded.about.clone(), }]; (extensions, Some(loaded)) } else { - let extensions = load_extensions_catalog()?; + apply_builderlab_mode_if_needed(&mut kgoose_config, config.builderlab_mode)?; + let extensions = load_tools_extensions(&client, &kgoose_config, config.builderlab_mode)?; (extensions, None) }; @@ -460,7 +465,7 @@ fn load_command_description( .collect::>(); match command_path.as_slice() { - [] => load_root_command_description(client, config, tools_cli.describe_root_name), + [] => load_root_command_description(client, config, tools_cli), [APPKIT_COMMAND_NAME] => Ok(appkit_command_description()), [APPKIT_COMMAND_NAME, ..] => { anyhow::bail!( @@ -468,13 +473,27 @@ fn load_command_description( tools_cli.nested_appkit_help ) } - [extension_name] => load_extension_command_description(client, config, extension_name), + [extension_name] => load_extension_command_description( + client, + config, + extension_name, + tools_cli.builderlab_mode, + ), [extension_name, EXTENSION_DESCRIBE_COMMAND_NAME] => { - load_extension_describe_command_description(client, config, extension_name) - } - [extension_name, tool_name] => { - load_tool_command_description(client, config, extension_name, tool_name) + load_extension_describe_command_description( + client, + config, + extension_name, + tools_cli.builderlab_mode, + ) } + [extension_name, tool_name] => load_tool_command_description( + client, + config, + extension_name, + tool_name, + tools_cli.builderlab_mode, + ), _ => anyhow::bail!( "`--describe-commands` only supports the root command, an extension, or a tool path" ), @@ -482,31 +501,55 @@ fn load_command_description( } fn load_root_command_description( - _client: &impl KgooseClient, - _config: &KgooseConfig, - root_name: &str, + client: &impl KgooseClient, + config: &KgooseConfig, + tools_cli: ToolsCliConfig, ) -> Result { - let extensions = load_extensions_catalog()?; - Ok(root_command_description(&extensions, root_name)) + let extensions = load_tools_extensions(client, config, tools_cli.builderlab_mode)?; + Ok(root_command_description( + &extensions, + tools_cli.describe_root_name, + )) } -/// Loads an extension by name, using the static catalog to produce helpful error -/// messages for extensions that exist but the user hasn't connected yet. -fn load_extension_with_catalog( +fn load_tools_extensions( + client: &impl KgooseClient, + config: &KgooseConfig, + builderlab_mode: bool, +) -> Result> { + if builderlab_mode { + load_extensions_with_auth_status(client, config) + } else { + load_extensions_catalog() + } +} + +/// Uses live discovery for BuilderLab errors and the static catalog for sq. +fn load_tools_extension( client: &impl KgooseClient, config: &KgooseConfig, extension_name: &str, + builderlab_mode: bool, ) -> Result { - let known_extensions = load_extensions_catalog()?; - load_extension(client, config, extension_name, &known_extensions) + if builderlab_mode { + load_extension(client, config, extension_name, || { + load_extensions(client, config).map(Some) + }) + } else { + let known_extensions = load_extensions_catalog()?; + load_extension(client, config, extension_name, || { + Ok((!known_extensions.is_empty()).then_some(known_extensions)) + }) + } } fn load_extension_command_description( client: &impl KgooseClient, config: &KgooseConfig, extension_name: &str, + builderlab_mode: bool, ) -> Result { - let loaded = load_extension_with_catalog(client, config, extension_name)?; + let loaded = load_tools_extension(client, config, extension_name, builderlab_mode)?; Ok(extension_command_description(&loaded)) } @@ -515,8 +558,9 @@ fn load_tool_command_description( config: &KgooseConfig, extension_name: &str, tool_name: &str, + builderlab_mode: bool, ) -> Result { - let loaded = load_extension_with_catalog(client, config, extension_name)?; + let loaded = load_tools_extension(client, config, extension_name, builderlab_mode)?; let tool = loaded .tools .iter() @@ -530,8 +574,9 @@ fn load_extension_describe_command_description( client: &impl KgooseClient, config: &KgooseConfig, extension_name: &str, + builderlab_mode: bool, ) -> Result { - load_extension_with_catalog(client, config, extension_name)?; + load_tools_extension(client, config, extension_name, builderlab_mode)?; Ok(extension_describe_command_description()) } diff --git a/src/runtime.rs b/src/runtime.rs index ef6f320..76f5945 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -83,41 +83,67 @@ pub fn load_extensions( config: &KgooseConfig, ) -> Result> { let response = client.list_extensions(config)?; - Ok(sort_extensions(response) + Ok(extension_summaries(response, false)) +} + +pub fn load_extensions_with_auth_status( + client: &impl KgooseClient, + config: &KgooseConfig, +) -> Result> { + let response = client.list_extensions(config)?; + Ok(extension_summaries(response, true)) +} + +fn extension_summaries( + response: ListExtensionsResponse, + show_auth_status: bool, +) -> Vec { + sort_extensions(response) .into_iter() .filter_map(|extension| { - let name = extension.name?; + let about = compact_text( + extension + .description + .as_deref() + .unwrap_or(&format!("{} tools", extension.tool_count.unwrap_or(0))), + ); Some(ExtensionSummary { - about: compact_text( - extension - .description - .as_deref() - .unwrap_or(&format!("{} tools", extension.tool_count.unwrap_or(0))), - ), - name, + about: if show_auth_status && extension.auth_satisfied_for_caller == Some(false) { + format!("[not connected] {about}") + } else { + about + }, + name: extension.name?, }) }) - .collect()) + .collect() } pub fn load_extension( client: &impl KgooseClient, config: &KgooseConfig, extension_name: &str, - known_extensions: &[ExtensionSummary], + load_known_extensions: impl FnOnce() -> Result>>, ) -> Result { let response = match client.list_tools(config, extension_name) { Ok(response) => response, Err(err) => { - // The static catalog covers all known extensions (both live API and late-init - // OAuth extensions like `asana` or `notion`). Using it here lets us distinguish - // "unknown extension" (typo) from "known but not yet connected" and surface a - // helpful G2 Connections hint for the latter. + // Discovery is only needed to distinguish unknown extensions from configured + // but inaccessible ones. Keep successful ListTools calls independent of it. + let Some(known_extensions) = load_known_extensions() + .with_context(|| format!("ListTools for `{extension_name}` failed: {err:#}"))? + else { + return Err(humanize_list_tools_error( + extension_name, + config.playpen.as_deref(), + &err, + )); + }; let is_known = known_extensions .iter() .any(|extension| extension.name == extension_name); - if is_known || known_extensions.is_empty() { + if is_known { return Err(humanize_list_tools_error( extension_name, config.playpen.as_deref(), @@ -125,6 +151,10 @@ pub fn load_extension( )); } + if known_extensions.is_empty() { + anyhow::bail!("unknown extension `{extension_name}` (no extensions configured in this backend)"); + } + anyhow::bail!( "unknown extension `{extension_name}` (available: {})", known_extensions @@ -670,8 +700,10 @@ mod tests { #[test] fn load_extension_discovers_tools_and_schema_parameters() { - let extension = - load_extension(&TestKgooseClient, &kgoose_config(), "utils", &[]).expect("extension"); + let extension = load_extension(&TestKgooseClient, &kgoose_config(), "utils", || { + panic!("successful ListTools must not load extension discovery") + }) + .expect("extension"); assert_eq!(extension.name, "utils"); assert_eq!(extension.about, "Utility helpers"); assert_eq!(extension.description, "Utility helpers"); @@ -702,7 +734,7 @@ mod tests { &UnauthorizedKgooseClient, &kgoose_config(), "airtable", - &known, + || Ok(Some(known)), ) .expect_err("expected inaccessible extension"); @@ -723,7 +755,7 @@ mod tests { &ConnectFailureKgooseClient, &kgoose_config(), "sourcegraph", - &known, + || Ok(Some(known)), ) .expect_err("expected connect failure"); @@ -740,15 +772,12 @@ mod tests { #[test] fn load_extension_rewrites_catalog_known_inaccessible_errors_for_humans() { - let error = load_extension( - &UnauthorizedKgooseClient, - &kgoose_config(), - "asana", - &[ExtensionSummary { + let error = load_extension(&UnauthorizedKgooseClient, &kgoose_config(), "asana", || { + Ok(Some(vec![ExtensionSummary { name: "asana".to_string(), about: "Asana tools".to_string(), - }], - ) + }])) + }) .expect_err("expected inaccessible extension"); let message = error.to_string(); diff --git a/tests/bl_e2e.rs b/tests/bl_e2e.rs index 516b250..dee6d11 100644 --- a/tests/bl_e2e.rs +++ b/tests/bl_e2e.rs @@ -1259,7 +1259,7 @@ fn bl_root_description_lists_apps_not_appkit() { } #[test] -fn bl_tools_root_help_does_not_require_org() { +fn bl_tools_root_help_requires_org_without_blox_proxy_auth() { let temp = temp_test_dir("bl-tools-help-no-org"); let bl_home = temp.join("bl-home"); fs::create_dir_all(&bl_home).expect("create bl home"); @@ -1269,11 +1269,13 @@ fn bl_tools_root_help_does_not_require_org() { .args(["tools", "--help"]) .output() .expect("run bl tools help"); - let (stdout, stderr) = output_text(&output); + let (_, stderr) = output_text(&output); - assert!(output.status.success(), "stderr was: {stderr}"); - assert!(stdout.contains("Discover auth-backed tool extensions")); - assert!(!stderr.contains("org_required"), "stderr was: {stderr}"); + assert_eq!(output.status.code(), Some(3), "stderr was: {stderr}"); + assert!( + stderr.contains("org is not configured"), + "stderr was: {stderr}" + ); fs::remove_dir_all(temp).expect("remove temp dir"); } @@ -4222,6 +4224,81 @@ fn bl_tools_blox_lists_extensions_without_local_configuration() { fs::remove_dir_all(temp).expect("remove temp dir"); } +#[test] +fn bl_tools_root_help_lists_only_configured_extensions_and_marks_missing_credentials() { + for args in [vec!["tools"], vec!["tools", "--help"]] { + let server = MockServer::start(vec![MockResponse::json(json!({ + "extensions": [ + { + "name": "slack", + "description": "Slack tools", + "authSatisfiedForCaller": true + }, + { + "name": "notion", + "description": "Notion tools", + "tool_count": 0, + "auth_satisfied_for_caller": false + } + ] + }))]); + let temp = temp_test_dir("bl-tools-live-help"); + fs::write(temp.join("sessions.json"), "not json") + .expect("write unreadable session storage"); + let output = blox_tools_command(&temp, &server.base_url) + .env( + "KGOOSE_EXTENSIONS_CATALOG", + temp.join("missing-catalog.yaml"), + ) + .env("STS_ACCESS_TOKEN", "ambient-sts-token") + .args(args) + .output() + .expect("list configured extensions"); + let (stdout, stderr) = output_text(&output); + let requests = server.finish(); + + assert!(output.status.success(), "stderr was: {stderr}"); + // clap prints implicit help for a missing subcommand to stderr. + let help = format!("{stdout}{stderr}"); + assert!( + help.contains("[not connected] Notion tools"), + "help was: {help}" + ); + assert!(help.contains("Slack tools"), "help was: {help}"); + assert!(!help.contains("[not connected] Slack tools")); + assert!(!help.contains("linear"), "help was: {help}"); + assert!(help.find("notion").unwrap() < help.find("slack").unwrap()); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].path, LIST_EXTENSIONS_PATH); + assert!(!requests[0].headers.contains_key("x-bb-session-credential")); + assert!(!requests[0] + .headers + .contains_key("x-forwarded-identity-token")); + fs::remove_dir_all(temp).expect("remove temp dir"); + } +} + +#[test] +fn bl_tools_root_help_reports_discovery_errors_without_catalog_fallback() { + let server = MockServer::start(vec![MockResponse::text(503, "discovery unavailable")]); + let output = server + .bl_tools_command() + .arg("--help") + .output() + .expect("run bl tools help"); + let requests = server.finish(); + let (stdout, stderr) = output_text(&output); + + assert!(!output.status.success()); + assert!(stdout.is_empty(), "stdout was: {stdout}"); + assert!( + stderr.contains("discovery unavailable"), + "stderr was: {stderr}" + ); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].path, "/api/v3/list-extensions"); +} + #[test] fn bl_tools_blox_preserves_same_origin_url_and_explicit_service_path() { let server = MockServer::start(vec![list_tools_response( @@ -4558,11 +4635,7 @@ fn bl_tools_root_metadata_commands_do_not_read_auth_storage() { let malformed_storage = temp.join("auth-sessions.json"); fs::write(&malformed_storage, "not json").expect("write malformed auth storage"); - for args in [ - vec!["--version"], - vec!["--summary"], - vec!["--describe-commands"], - ] { + for args in [vec!["--version"], vec!["--summary"]] { let server = MockServer::start(vec![]); let output = server .bl_tools_command() @@ -4582,27 +4655,186 @@ fn bl_tools_root_metadata_commands_do_not_read_auth_storage() { } #[test] -fn bl_tools_describe_commands_uses_static_catalog_without_network() { - let server = MockServer::start(vec![]); +fn bl_tools_unconnected_extensions_use_live_discovery_for_help_and_metadata_errors() { + let temp = temp_test_dir("bl-tools-live-unconnected"); + for args in [ + vec!["playpen-only", "--help"], + vec!["playpen-only", "calculate", "--help"], + vec!["playpen-only", "describe"], + vec!["playpen-only", "--describe-commands"], + vec!["playpen-only", "calculate", "--describe-commands"], + vec!["playpen-only", "describe", "--describe-commands"], + vec!["playpen-only", "--summary"], + ] { + let server = MockServer::start(vec![ + MockResponse::text(404, "Extension 'playpen-only' not found or not authorized"), + MockResponse::json(json!({ + "extensions": [{ + "name": "playpen-only", + "description": "Configured in this backend", + "authSatisfiedForCaller": false, + "tool_count": 0 + }] + })), + ]); + let output = server + .bl_tools_command() + .env( + "KGOOSE_EXTENSIONS_CATALOG", + temp.join("missing-catalog.yaml"), + ) + .args(args) + .output() + .expect("inspect unconnected extension"); + let (_, stderr) = output_text(&output); + let requests = server.finish(); + + assert!(!output.status.success()); + assert!( + stderr.contains("not connected in your account"), + "stderr was: {stderr}" + ); + assert!( + !stderr.contains("unknown extension"), + "stderr was: {stderr}" + ); + assert_eq!(requests.len(), 2); + assert_eq!(requests[0].path, BL_TOOLS_LIST_TOOLS_PATH); + assert_eq!(requests[1].path, "/api/v3/list-extensions"); + } + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_tools_unknown_extensions_are_classified_against_the_live_backend() { + for (extensions, expected) in [ + ( + json!([{"name": "backend-only", "authSatisfiedForCaller": true}]), + "available: backend-only", + ), + (json!([]), "no extensions configured in this backend"), + ] { + let server = MockServer::start(vec![ + MockResponse::text(404, "Extension 'slack' not found or not authorized"), + MockResponse::json(json!({"extensions": extensions})), + ]); + let output = server + .bl_tools_command() + .args(["slack", "--help"]) + .output() + .expect("inspect extension absent from backend"); + let (_, stderr) = output_text(&output); + let requests = server.finish(); + + assert!(!output.status.success()); + assert!( + stderr.contains("unknown extension `slack`"), + "stderr was: {stderr}" + ); + assert!(stderr.contains(expected), "stderr was: {stderr}"); + assert!(!stderr.contains("not connected in your account")); + assert_eq!(requests.len(), 2); + } +} + +#[test] +fn bl_tools_successful_extension_inspection_does_not_load_a_catalog() { + let temp = temp_test_dir("bl-tools-no-catalog-inspection"); + for args in [ + vec!["playpen-only", "--help"], + vec!["playpen-only", "calculate", "--help"], + vec!["playpen-only", "describe"], + vec!["playpen-only", "--describe-commands"], + vec!["playpen-only", "calculate", "--describe-commands"], + vec!["playpen-only", "describe", "--describe-commands"], + vec!["playpen-only", "--summary"], + ] { + let server = MockServer::start(vec![list_tools_response( + "playpen-only", + calculate_tool_schema(false), + )]); + let output = server + .bl_tools_command() + .env( + "KGOOSE_EXTENSIONS_CATALOG", + temp.join("missing-catalog.yaml"), + ) + .args(args) + .output() + .expect("inspect configured extension"); + let (_, stderr) = output_text(&output); + let requests = server.finish(); + + assert!(output.status.success(), "stderr was: {stderr}"); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].path, BL_TOOLS_LIST_TOOLS_PATH); + } + fs::remove_dir_all(temp).expect("remove temp dir"); +} + +#[test] +fn bl_tools_failed_discovery_preserves_the_original_tool_lookup_error() { + let server = MockServer::start(vec![ + MockResponse::text(404, "tool lookup failed"), + MockResponse::text(503, "extension discovery unavailable"), + ]); + let output = server + .bl_tools_command() + .args(["playpen-only", "--help"]) + .output() + .expect("inspect extension while discovery is unavailable"); + let (_, stderr) = output_text(&output); + let requests = server.finish(); + + assert!(!output.status.success()); + assert!( + stderr.contains("tool lookup failed"), + "stderr was: {stderr}" + ); + assert!( + stderr.contains("extension discovery unavailable"), + "stderr was: {stderr}" + ); + assert_eq!(requests.len(), 2); +} + +#[test] +fn bl_tools_describe_commands_uses_configured_extensions_and_connection_status() { + let server = MockServer::start(vec![MockResponse::json(json!({ + "extensions": [ + {"name": "utils", "description": "Utility helpers", "authSatisfiedForCaller": true}, + {"name": "secret", "description": "Needs more auth", "authSatisfiedForCaller": false} + ] + }))]); let catalog_path = write_extensions_catalog( "bl-tools-describe-commands", r#" -- name: secret - about: Needs more auth -- name: utils - about: Utility helpers +- name: catalog-only + about: Should not appear "#, ); + let temp = temp_test_dir("bl-tools-live-discovery-session"); + let storage_path = temp.join("auth-sessions.json"); + let storage_key = + browser_auth_storage_key("default", &format!("{}/cash-app/goose", server.base_url)); + fs::write( + &storage_path, + json!({storage_key: {"sessionCredential": "discovery-session"}}).to_string(), + ) + .expect("write auth storage"); let output = server .bl_tools_command() .env("KGOOSE_EXTENSIONS_CATALOG", &catalog_path) + .env("BL_AUTH_STORAGE", "file") + .env("BL_AUTH_STORAGE_FILE", &storage_path) .arg("--describe-commands") .output() .expect("run bl tools describe-commands"); let requests = server.finish(); let (stdout, stderr) = output_text(&output); fs::remove_file(&catalog_path).expect("remove extensions catalog"); + fs::remove_dir_all(temp).expect("remove temp dir"); assert!(output.status.success(), "stderr was: {stderr}"); let description = serde_json::from_str::(&stdout).expect("parse describe output"); @@ -4616,7 +4848,7 @@ fn bl_tools_describe_commands_uses_static_catalog_without_network() { }, { "name": "secret", - "summary": "Needs more auth" + "summary": "[not connected] Needs more auth" }, { "name": "utils", @@ -4625,5 +4857,13 @@ fn bl_tools_describe_commands_uses_static_catalog_without_network() { ]) ); assert!(stderr.is_empty(), "stderr was: {stderr}"); - assert!(requests.is_empty(), "requests were: {requests:#?}"); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].path, "/api/v3/list-extensions"); + assert_eq!( + requests[0] + .headers + .get("x-bb-session-credential") + .map(String::as_str), + Some("discovery-session") + ); }