This commit is contained in:
Matthew Zeng
2026-03-10 12:33:04 -07:00
parent 4c7a1d800f
commit 5ef74e74b5
11 changed files with 302 additions and 38 deletions
+17 -1
View File
@@ -6281,6 +6281,21 @@ async fn built_tools(
&effective_explicitly_enabled_connectors,
)
});
let search_tool_connectors =
if turn_context.tools_config.search_tool && turn_context.apps_enabled() {
match connectors::list_cached_connectors(&turn_context.config).await {
Some(connectors) => Some(connectors),
None => match connectors::list_connectors(&turn_context.config).await {
Ok(connectors) => Some(connectors),
Err(err) => {
warn!("failed to load connectors for search tool description: {err}");
None
}
},
}
} else {
None
};
if let Some(connectors) = connectors.as_ref() {
let skill_name_counts_lower = skills_outcome.map_or_else(HashMap::new, |outcome| {
@@ -6312,7 +6327,7 @@ async fn built_tools(
);
}
Ok(Arc::new(ToolRouter::from_config(
Ok(Arc::new(ToolRouter::from_config_with_connectors(
&turn_context.tools_config,
has_mcp_servers.then(|| {
mcp_tools
@@ -6322,6 +6337,7 @@ async fn built_tools(
}),
app_tools,
turn_context.dynamic_tools.as_slice(),
search_tool_connectors.as_deref(),
)))
}
+1
View File
@@ -52,6 +52,7 @@ pub(crate) use request_permissions::request_permissions_tool_description;
pub use request_user_input::RequestUserInputHandler;
pub(crate) use request_user_input::request_user_input_tool_description;
pub(crate) use search_tool_bm25::DEFAULT_LIMIT as SEARCH_TOOL_BM25_DEFAULT_LIMIT;
pub(crate) use search_tool_bm25::INSTALLABLE_DISCOVERABLE_CONNECTOR_IDS;
pub(crate) use search_tool_bm25::SEARCH_TOOL_BM25_TOOL_NAME;
pub use search_tool_bm25::SearchToolBm25Handler;
pub use shell::ShellCommandHandler;
@@ -8,6 +8,7 @@ use serde::Serialize;
use serde_json::json;
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::LazyLock;
use crate::connectors;
use crate::function_tool::FunctionCallError;
@@ -26,6 +27,9 @@ pub struct SearchToolBm25Handler;
pub(crate) const SEARCH_TOOL_BM25_TOOL_NAME: &str = "search_tool_bm25";
pub(crate) const DEFAULT_LIMIT: usize = 8;
const ALLOWLISTED_DISCOVERABLE_CONNECTOR_ID: &str = "connector_2128aebfecb84f64a069897515042a44";
pub(crate) static INSTALLABLE_DISCOVERABLE_CONNECTOR_IDS: LazyLock<HashSet<&str>> =
LazyLock::new(|| HashSet::from([ALLOWLISTED_DISCOVERABLE_CONNECTOR_ID]));
fn default_limit() -> usize {
DEFAULT_LIMIT
@@ -337,7 +341,10 @@ fn discoverable_connector_entries(
for connector in connectors {
if !connector.is_accessible {
installable_entries.push(ConnectorEntry::new(connector, ToolSuggestionType::Install));
if INSTALLABLE_DISCOVERABLE_CONNECTOR_IDS.contains(connector.id.as_str()) {
installable_entries
.push(ConnectorEntry::new(connector, ToolSuggestionType::Install));
}
} else if !connector.is_enabled {
disabled_entries.push(ConnectorEntry::new(connector, ToolSuggestionType::Enable));
}
@@ -609,6 +616,21 @@ mod tests {
#[test]
fn discoverable_connector_entries_prioritize_install_before_enable() {
let installable = AppInfo {
id: ALLOWLISTED_DISCOVERABLE_CONNECTOR_ID.to_string(),
name: "Calendar".to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: None,
is_accessible: false,
is_enabled: false,
plugin_display_names: Vec::new(),
};
let non_allowlisted_installable = AppInfo {
id: "calendar".to_string(),
name: "Calendar".to_string(),
description: None,
@@ -639,15 +661,21 @@ mod tests {
plugin_display_names: Vec::new(),
};
let (installable_entries, disabled_entries) =
discoverable_connector_entries(vec![disabled, installable]);
let (installable_entries, disabled_entries) = discoverable_connector_entries(vec![
disabled,
installable,
non_allowlisted_installable,
]);
assert_eq!(
installable_entries
.iter()
.map(|entry| (entry.connector_id.clone(), entry.suggestion_type))
.collect::<Vec<_>>(),
vec![("calendar".to_string(), ToolSuggestionType::Install)]
vec![(
ALLOWLISTED_DISCOVERABLE_CONNECTOR_ID.to_string(),
ToolSuggestionType::Install,
)]
);
assert_eq!(
disabled_entries
@@ -68,6 +68,7 @@ struct ToolSuggestArgs {
connector_id: String,
tool_type: ToolSuggestionToolType,
suggestion_type: ToolSuggestionType,
suggest_reason: Option<String>,
}
#[derive(Deserialize)]
@@ -78,6 +79,7 @@ struct ToolSuggestElicitationContent {
fn tool_suggest_message(
suggestion_type: ToolSuggestionType,
connector_name: &str,
suggest_reason: Option<&str>,
connector_description: Option<&str>,
url: &str,
) -> String {
@@ -85,15 +87,27 @@ fn tool_suggest_message(
"{} {connector_name} to continue?",
suggestion_type.verb()
)];
if let Some(description) = connector_description.map(str::trim)
&& !description.is_empty()
{
parts.push(description.to_string());
if let Some(suggest_reason) = normalized_optional_text(suggest_reason) {
parts.push(format!("Reason: {suggest_reason}"));
}
if let Some(description) = normalized_optional_text(connector_description) {
parts.push(description);
}
parts.push(format!("Open URL: {url}"));
parts.join(" | ")
}
fn normalized_optional_text(text: Option<&str>) -> Option<String> {
text.and_then(|text| {
let normalized = text.split_whitespace().collect::<Vec<_>>().join(" ");
if normalized.is_empty() {
None
} else {
Some(normalized)
}
})
}
fn tool_suggest_requested_schema() -> McpElicitationSchema {
McpElicitationSchema {
schema_uri: None,
@@ -108,10 +122,11 @@ fn tool_suggest_elicitation_meta(
suggestion_type: ToolSuggestionType,
connector_id: &str,
connector_name: &str,
suggest_reason: Option<&str>,
connector_description: Option<&str>,
install_url: &str,
) -> Value {
json!({
let mut meta = json!({
TOOL_SUGGEST_META_KIND_KEY: TOOL_SUGGEST_META_KIND_VALUE,
"tool_type": tool_type,
"suggestion_type": suggestion_type,
@@ -119,7 +134,13 @@ fn tool_suggest_elicitation_meta(
"connector_name": connector_name,
"connector_description": connector_description,
"install_url": install_url,
})
});
if let Some(suggest_reason) = suggest_reason
&& let Some(meta) = meta.as_object_mut()
{
meta.insert("suggest_reason".to_string(), json!(suggest_reason));
}
meta
}
fn parse_tool_suggest_elicitation_response(
@@ -246,6 +267,7 @@ impl ToolHandler for ToolSuggestHandler {
.install_url
.clone()
.unwrap_or_else(|| connectors::connector_install_url(&connector.name, &connector.id));
let suggest_reason = normalized_optional_text(args.suggest_reason.as_deref());
let request_id = RequestId::String(format!("{TOOL_SUGGEST_TOOL_NAME}_{call_id}").into());
let elicitation_response = session
.request_mcp_server_elicitation(
@@ -261,12 +283,14 @@ impl ToolHandler for ToolSuggestHandler {
args.suggestion_type,
&connector.id,
&connector.name,
suggest_reason.as_deref(),
connector.description.as_deref(),
&install_url,
)),
message: tool_suggest_message(
args.suggestion_type,
&connector.name,
suggest_reason.as_deref(),
connector.description.as_deref(),
&install_url,
),
@@ -291,14 +315,19 @@ impl ToolHandler for ToolSuggestHandler {
.await;
}
let user_declined_suggestion =
user_decision == TOOL_SUGGEST_DECISION_NOT_NOW && elicitation_action != "cancel";
let assistant_instruction = if user_decision == TOOL_SUGGEST_DECISION_INSTALL {
"The user confirmed they completed the install flow. Treat this connector as selectable for this turn, but verify its tools appear in a `search_tool_bm25` search with `mode: \"enabled\"` before trying to use them. If they still do not appear, it may also need enabling."
} else if user_decision == TOOL_SUGGEST_DECISION_ENABLE {
"The user confirmed they enabled this connector. Treat it as selectable for this turn, but verify its tools appear in a `search_tool_bm25` search with `mode: \"enabled\"` before trying to use them."
} else if user_declined_suggestion {
"The user declined this suggestion. Do not ask them again to install or enable this connector unless they later ask for it. Do not try to use this tool in this turn."
} else {
"The user did not complete this suggestion flow. Do not try to use this tool in this turn."
};
let content = json!({
let mut content = json!({
"connector_id": connector.id,
"connector_name": connector.name,
"connector_description": connector.description,
@@ -308,8 +337,13 @@ impl ToolHandler for ToolSuggestHandler {
"elicitation_action": elicitation_action,
"user_decision": user_decision,
"assistant_instruction": assistant_instruction,
})
.to_string();
});
if let Some(suggest_reason) = suggest_reason
&& let Some(content) = content.as_object_mut()
{
content.insert("suggest_reason".to_string(), json!(suggest_reason));
}
let content = content.to_string();
Ok(FunctionToolOutput::from_text(content, Some(true)))
}
@@ -333,13 +367,14 @@ mod tests {
let message = tool_suggest_message(
ToolSuggestionType::Install,
"Docs & Notes",
Some("Need access to\n workspace docs"),
Some("Install <now> & sync"),
"https://example.com/apps?name=Docs",
);
assert_eq!(
message,
"Install Docs & Notes to continue? | Install <now> & sync | Open URL: https://example.com/apps?name=Docs"
"Install Docs & Notes to continue? | Reason: Need access to workspace docs | Install <now> & sync | Open URL: https://example.com/apps?name=Docs"
);
}
+16
View File
@@ -1,6 +1,7 @@
use crate::client_common::tools::ToolSpec;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::connectors::AppInfo;
use crate::function_tool::FunctionCallError;
use crate::mcp_connection_manager::ToolInfo;
use crate::sandboxing::SandboxPermissions;
@@ -11,6 +12,7 @@ use crate::tools::registry::ConfiguredToolSpec;
use crate::tools::registry::ToolRegistry;
use crate::tools::spec::ToolsConfig;
use crate::tools::spec::build_specs;
use crate::tools::spec::build_specs_with_connectors;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::LocalShellAction;
@@ -49,6 +51,20 @@ impl ToolRouter {
Self { registry, specs }
}
pub fn from_config_with_connectors(
config: &ToolsConfig,
mcp_tools: Option<HashMap<String, Tool>>,
app_tools: Option<HashMap<String, ToolInfo>>,
dynamic_tools: &[DynamicToolSpec],
connectors: Option<&[AppInfo]>,
) -> Self {
let builder =
build_specs_with_connectors(config, mcp_tools, app_tools, dynamic_tools, connectors);
let (specs, registry) = builder.build();
Self { registry, specs }
}
pub fn specs(&self) -> Vec<ToolSpec> {
self.specs
.iter()
+83 -7
View File
@@ -3,10 +3,12 @@ use crate::client_common::tools::FreeformToolFormat;
use crate::client_common::tools::ResponsesApiTool;
use crate::client_common::tools::ToolSpec;
use crate::config::AgentRoleConfig;
use crate::connectors::AppInfo;
use crate::features::Feature;
use crate::features::Features;
use crate::mcp_connection_manager::ToolInfo;
use crate::models_manager::collaboration_mode_presets::CollaborationModesConfig;
use crate::tools::handlers::INSTALLABLE_DISCOVERABLE_CONNECTOR_IDS;
use crate::tools::handlers::PLAN_TOOL;
use crate::tools::handlers::SEARCH_TOOL_BM25_DEFAULT_LIMIT;
use crate::tools::handlers::SEARCH_TOOL_BM25_TOOL_NAME;
@@ -1270,7 +1272,10 @@ fn create_grep_files_tool() -> ToolSpec {
})
}
fn create_search_tool_bm25_tool(app_tools: &HashMap<String, ToolInfo>) -> ToolSpec {
fn create_search_tool_bm25_tool(
app_tools: &HashMap<String, ToolInfo>,
connectors: Option<&[AppInfo]>,
) -> ToolSpec {
let properties = BTreeMap::from([
(
"query".to_string(),
@@ -1303,13 +1308,35 @@ fn create_search_tool_bm25_tool(app_tools: &HashMap<String, ToolInfo>) -> ToolSp
app_names.sort();
app_names.dedup();
let app_names = app_names.join(", ");
let mut discoverable_connector_names = connectors
.unwrap_or_default()
.iter()
.filter(|connector| INSTALLABLE_DISCOVERABLE_CONNECTOR_IDS.contains(connector.id.as_str()))
.map(|connector| connector.name.as_str())
.collect::<Vec<_>>();
discoverable_connector_names.sort_unstable();
discoverable_connector_names.dedup();
let discoverable_connector_names = if discoverable_connector_names.is_empty() {
"connector metadata unavailable".to_string()
} else {
discoverable_connector_names.join(", ")
};
let description = if app_names.is_empty() {
SEARCH_TOOL_BM25_DESCRIPTION_TEMPLATE
.replace("({{app_names}})", "(None currently enabled)")
.replace("{{app_names}}", "enabled apps")
.replace(
"{{discoverable_connector_names}}",
discoverable_connector_names.as_str(),
)
} else {
SEARCH_TOOL_BM25_DESCRIPTION_TEMPLATE.replace("{{app_names}}", app_names.as_str())
SEARCH_TOOL_BM25_DESCRIPTION_TEMPLATE
.replace("{{app_names}}", app_names.as_str())
.replace(
"{{discoverable_connector_names}}",
discoverable_connector_names.as_str(),
)
};
ToolSpec::Function(ResponsesApiTool {
@@ -1353,11 +1380,22 @@ fn create_tool_suggest_tool() -> ToolSpec {
),
},
),
(
"suggest_reason".to_string(),
JsonSchema::String {
description: Some(
"Optional brief, user-facing reason for why this connector should be installed or enabled now."
.to_string(),
),
},
),
]);
ToolSpec::Function(ResponsesApiTool {
name: TOOL_SUGGEST_TOOL_NAME.to_string(),
description: "Prompt the user to install or enable a discoverable connector.".to_string(),
description:
"Prompt the user to install or enable a discoverable connector, optionally with a reason."
.to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
@@ -1923,6 +1961,16 @@ pub(crate) fn build_specs(
mcp_tools: Option<HashMap<String, rmcp::model::Tool>>,
app_tools: Option<HashMap<String, ToolInfo>>,
dynamic_tools: &[DynamicToolSpec],
) -> ToolRegistryBuilder {
build_specs_with_connectors(config, mcp_tools, app_tools, dynamic_tools, None)
}
pub(crate) fn build_specs_with_connectors(
config: &ToolsConfig,
mcp_tools: Option<HashMap<String, rmcp::model::Tool>>,
app_tools: Option<HashMap<String, ToolInfo>>,
dynamic_tools: &[DynamicToolSpec],
connectors: Option<&[AppInfo]>,
) -> ToolRegistryBuilder {
use crate::tools::handlers::ApplyPatchHandler;
use crate::tools::handlers::ArtifactsHandler;
@@ -1973,11 +2021,12 @@ pub(crate) fn build_specs(
if config.code_mode_enabled {
let nested_config = config.for_code_mode_nested_tools();
let (nested_specs, _) = build_specs(
let (nested_specs, _) = build_specs_with_connectors(
&nested_config,
mcp_tools.clone(),
app_tools.clone(),
dynamic_tools,
connectors,
)
.build();
let mut enabled_tool_names = nested_specs
@@ -2062,7 +2111,10 @@ pub(crate) fn build_specs(
if config.search_tool {
let app_tools = app_tools.unwrap_or_default();
builder.push_spec_with_parallel_support(create_search_tool_bm25_tool(&app_tools), true);
builder.push_spec_with_parallel_support(
create_search_tool_bm25_tool(&app_tools, connectors),
true,
);
builder.push_spec_with_parallel_support(create_tool_suggest_tool(), true);
builder.register_handler(SEARCH_TOOL_BM25_TOOL_NAME, search_tool_handler);
builder.register_handler(TOOL_SUGGEST_TOOL_NAME, tool_suggest_handler);
@@ -3527,6 +3579,7 @@ mod tests {
assert!(properties.contains_key("connector_id"));
assert!(properties.contains_key("tool_type"));
assert!(properties.contains_key("suggestion_type"));
assert!(properties.contains_key("suggest_reason"));
assert_eq!(
required.as_deref(),
Some(
@@ -3596,15 +3649,38 @@ mod tests {
session_source: SessionSource::Cli,
});
let (tools, _) = build_specs(&tools_config, None, Some(HashMap::new()), &[]).build();
let discoverable_connectors = vec![AppInfo {
id: "connector_2128aebfecb84f64a069897515042a44".to_string(),
name: "Docs Connector".to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
install_url: None,
branding: None,
app_metadata: None,
labels: None,
is_accessible: false,
is_enabled: true,
plugin_display_names: Vec::new(),
}];
let (tools, _) = build_specs_with_connectors(
&tools_config,
None,
Some(HashMap::new()),
&[],
Some(&discoverable_connectors),
)
.build();
let search_tool = find_tool(&tools, SEARCH_TOOL_BM25_TOOL_NAME);
let ToolSpec::Function(ResponsesApiTool { description, .. }) = &search_tool.spec else {
panic!("expected function tool");
};
assert!(description.contains("(None currently enabled)"));
assert!(description.contains("enabled apps."));
assert!(description.contains("Docs Connector"));
assert!(!description.contains("{{app_names}}"));
assert!(!description.contains("{{discoverable_connector_names}}"));
}
#[test]
@@ -3,6 +3,7 @@
Searches over apps metadata with BM25 and exposes matching enabled tools for the next model call, or discoverable apps when requested.
MCP tools of the apps ({{app_names}}) are hidden until you search for them with this tool (`search_tool_bm25`).
Known discoverable tools (connectors, plugins) via this tool: {{discoverable_connector_names}}.
Follow this workflow:
@@ -17,7 +18,7 @@ Follow this workflow:
6. `discoverable` results prefer:
- connectors that are not installed
- connectors that are installed but disabled
7. If `discoverable` finds the right app, call `tool_suggest` with the returned `connector_id`, `tool_type`, and `suggestion_type`.
7. If `discoverable` finds the right app, call `tool_suggest` with the returned `connector_id`, `tool_type`, and `suggestion_type`, include a concise one-liner, user-facing `suggest_reason` for the `tool_suggest` tool.
Notes:
- Core tools remain available without searching.
@@ -30,7 +31,8 @@ Notes:
- `description`
- `connector_name`
- input schema property keys (`input_keys`)
- In `discoverable` mode, `query` is matched against app metadata such as connector id, name, description, labels, categories, and plugin display names.
- In `discoverable` mode, `query` is matched against various kinds of tool metadata such as connector id, name, description, labels, categories, and plugin display names.
- `discoverable` can also surface connectors that are already installed but currently disabled, even if they are not listed above.
- If the needed app is already explicit in the prompt (for example `[$app-name](app://{connector_id})`) or already present in the current `tools` list, you can call that tool directly.
- Do not call app MCP tools for apps returned only by `discoverable` mode until the user installs or enables them.
- Do not use `search_tool_bm25` for non-apps/local tasks (filesystem, repo search, or shell-only workflows) or anything not related to {{app_names}}.
- Do not use `search_tool_bm25` for non-apps/local tasks (filesystem, repo search, or shell-only workflows) or anything not related to the specific tool names mentioned above.
+33 -13
View File
@@ -47,7 +47,7 @@ use wiremock::matchers::path;
const SEARCH_TOOL_INSTRUCTION_SNIPPETS: [&str; 2] = [
"Always search `mode: \"enabled\"` first.",
"If `discoverable` finds the right app, call `tool_suggest` with the returned `connector_id`, `tool_type`, and `suggestion_type`.",
"If `discoverable` finds the right app, call `tool_suggest` with the returned `connector_id`, `tool_type`, and `suggestion_type`, include a concise one-liner, user-facing `suggest_reason` for the `tool_suggest` tool.",
];
const SEARCH_TOOL_BM25_TOOL_NAME: &str = "search_tool_bm25";
const TOOL_SUGGEST_TOOL_NAME: &str = "tool_suggest";
@@ -57,6 +57,10 @@ const RMCP_ECHO_TOOL: &str = "mcp__rmcp__echo";
const RMCP_IMAGE_TOOL: &str = "mcp__rmcp__image";
const CALENDAR_CREATE_QUERY: &str = "create calendar event";
const CALENDAR_LIST_QUERY: &str = "list calendar events";
const ALLOWLISTED_INSTALLABLE_CONNECTOR_ID: &str = "connector_2128aebfecb84f64a069897515042a44";
const ALLOWLISTED_INSTALLABLE_CONNECTOR_NAME: &str = "Docs Connector";
const ALLOWLISTED_INSTALLABLE_CONNECTOR_DESCRIPTION: &str = "Approved workspace docs";
const ALLOWLISTED_INSTALLABLE_QUERY: &str = "approved workspace docs";
const NOTION_CONNECTOR_ID: &str = "notion";
const NOTION_CONNECTOR_NAME: &str = "Notion";
const NOTION_CONNECTOR_DESCRIPTION: &str = "Workspace docs and notes";
@@ -166,6 +170,14 @@ async fn mount_directory_connectors(server: &wiremock::MockServer) {
"appMetadata": {
"categories": ["docs"]
}
},
{
"id": ALLOWLISTED_INSTALLABLE_CONNECTOR_ID,
"name": ALLOWLISTED_INSTALLABLE_CONNECTOR_NAME,
"description": ALLOWLISTED_INSTALLABLE_CONNECTOR_DESCRIPTION,
"appMetadata": {
"categories": ["docs"]
}
}
],
"nextToken": null
@@ -429,6 +441,10 @@ async fn search_tool_adds_discovery_instructions_to_tool_description() -> Result
.all(|snippet| description.contains(snippet)),
"search tool description should include search tool workflow: {description:?}"
);
assert!(
description.contains("connector metadata unavailable"),
"search tool description should explain missing discoverable connector metadata: {description:?}"
);
Ok(())
}
@@ -1360,7 +1376,7 @@ async fn installable_search_returns_inaccessible_connectors_without_selecting_to
mount_directory_connectors(&server).await;
let call_id = "installable-search";
let args = json!({
"query": "notion docs",
"query": ALLOWLISTED_INSTALLABLE_QUERY,
"limit": 5,
"mode": "discoverable",
});
@@ -1388,7 +1404,7 @@ async fn installable_search_returns_inaccessible_connectors_without_selecting_to
let test = builder.build(&server).await?;
test.submit_turn_with_policies(
"find installable notion app",
"find installable docs app",
AskForApproval::Never,
SandboxPolicy::DangerFullAccess,
)
@@ -1425,7 +1441,7 @@ async fn installable_search_returns_inaccessible_connectors_without_selecting_to
);
assert_eq!(
connectors[0].get("connector_id").and_then(Value::as_str),
Some(NOTION_CONNECTOR_ID),
Some(ALLOWLISTED_INSTALLABLE_CONNECTOR_ID),
);
assert_eq!(
connectors[0].get("tool_type").and_then(Value::as_str),
@@ -1437,13 +1453,13 @@ async fn installable_search_returns_inaccessible_connectors_without_selecting_to
);
assert_eq!(
connectors[0].get("connector_name").and_then(Value::as_str),
Some(NOTION_CONNECTOR_NAME),
Some(ALLOWLISTED_INSTALLABLE_CONNECTOR_NAME),
);
assert_eq!(
connectors[0]
.get("connector_description")
.and_then(Value::as_str),
Some(NOTION_CONNECTOR_DESCRIPTION),
Some(ALLOWLISTED_INSTALLABLE_CONNECTOR_DESCRIPTION),
);
Ok(())
@@ -1481,7 +1497,7 @@ async fn installable_search_preserves_available_selection() -> Result<()> {
installable_call_id,
SEARCH_TOOL_BM25_TOOL_NAME,
&serde_json::to_string(&json!({
"query": "notion docs",
"query": ALLOWLISTED_INSTALLABLE_QUERY,
"limit": 5,
"mode": "discoverable",
}))?,
@@ -1505,7 +1521,7 @@ async fn installable_search_preserves_available_selection() -> Result<()> {
)
.await?;
test.submit_turn_with_policies(
"find installable notion app",
"find installable docs app",
AskForApproval::Never,
SandboxPolicy::DangerFullAccess,
)
@@ -1538,7 +1554,7 @@ async fn installable_search_preserves_available_selection() -> Result<()> {
.first()
.and_then(|connector| connector.get("connector_id"))
.and_then(Value::as_str),
Some(NOTION_CONNECTOR_ID),
Some(ALLOWLISTED_INSTALLABLE_CONNECTOR_ID),
);
Ok(())
@@ -1624,6 +1640,7 @@ async fn tool_suggest_prompts_install_via_elicitation() -> Result<()> {
"connector_id": NOTION_CONNECTOR_ID,
"tool_type": "connector",
"suggestion_type": "install",
"suggest_reason": "The user asked to access their workspace docs.",
});
let mock = mount_sse_sequence(
&server,
@@ -1664,7 +1681,7 @@ async fn tool_suggest_prompts_install_via_elicitation() -> Result<()> {
assert_eq!(
message,
format!(
"Install {NOTION_CONNECTOR_NAME} to continue? | {NOTION_CONNECTOR_DESCRIPTION} | Open URL: https://chatgpt.com/apps/notion/notion"
"Install {NOTION_CONNECTOR_NAME} to continue? | Reason: The user asked to access their workspace docs. | {NOTION_CONNECTOR_DESCRIPTION} | Open URL: https://chatgpt.com/apps/notion/notion"
)
);
assert_eq!(
@@ -1675,6 +1692,7 @@ async fn tool_suggest_prompts_install_via_elicitation() -> Result<()> {
"suggestion_type": "install",
"connector_id": NOTION_CONNECTOR_ID,
"connector_name": NOTION_CONNECTOR_NAME,
"suggest_reason": "The user asked to access their workspace docs.",
"connector_description": NOTION_CONNECTOR_DESCRIPTION,
"install_url": "https://chatgpt.com/apps/notion/notion",
}))
@@ -1719,6 +1737,10 @@ async fn tool_suggest_prompts_install_via_elicitation() -> Result<()> {
payload.get("connector_name").and_then(Value::as_str),
Some(NOTION_CONNECTOR_NAME),
);
assert_eq!(
payload.get("suggest_reason").and_then(Value::as_str),
Some("The user asked to access their workspace docs."),
);
assert_eq!(
payload.get("connector_description").and_then(Value::as_str),
Some(NOTION_CONNECTOR_DESCRIPTION),
@@ -1884,9 +1906,7 @@ async fn tool_suggest_reports_not_now_when_user_declines_install() -> Result<()>
payload
.get("assistant_instruction")
.and_then(Value::as_str)
.is_some_and(
|instruction| instruction.contains("did not complete this suggestion flow")
),
.is_some_and(|instruction| instruction.contains("Do not ask them again")),
"unexpected tool_suggest payload: {payload:?}"
);
+1
View File
@@ -2378,6 +2378,7 @@ impl App {
app_id,
title,
description,
suggest_reason: None,
instructions,
url,
is_installed,
@@ -84,6 +84,7 @@ pub(crate) struct AppLinkViewParams {
pub(crate) app_id: String,
pub(crate) title: String,
pub(crate) description: Option<String>,
pub(crate) suggest_reason: Option<String>,
pub(crate) instructions: String,
pub(crate) url: String,
pub(crate) is_installed: bool,
@@ -100,6 +101,7 @@ struct ToolSuggestionMeta {
suggestion_type: ToolSuggestionType,
connector_id: String,
connector_name: String,
suggest_reason: Option<String>,
connector_description: Option<String>,
install_url: String,
}
@@ -131,6 +133,7 @@ pub(crate) fn tool_suggestion_params_from_event(
app_id: meta.connector_id,
title: meta.connector_name,
description: meta.connector_description,
suggest_reason: meta.suggest_reason,
instructions,
url: meta.install_url,
is_installed,
@@ -148,6 +151,7 @@ pub(crate) struct AppLinkView {
app_id: String,
title: String,
description: Option<String>,
suggest_reason: Option<String>,
instructions: String,
url: String,
is_installed: bool,
@@ -166,6 +170,7 @@ impl AppLinkView {
app_id,
title,
description,
suggest_reason,
instructions,
url,
is_installed,
@@ -177,6 +182,7 @@ impl AppLinkView {
app_id,
title,
description,
suggest_reason,
instructions,
url,
is_installed,
@@ -328,6 +334,17 @@ impl AppLinkView {
lines.push(Line::from(line.into_owned().dim()));
}
}
if let Some(suggest_reason) = self
.suggest_reason
.as_deref()
.map(str::trim)
.filter(|suggest_reason| !suggest_reason.is_empty())
{
let suggest_reason = format!("Reason: {suggest_reason}");
for line in wrap(&suggest_reason, usable_width) {
lines.push(Line::from(line.into_owned()));
}
}
lines.push(Line::from(""));
if self.is_installed {
@@ -604,6 +621,7 @@ mod tests {
app_id: "connector_1".to_string(),
title: "Notion".to_string(),
description: None,
suggest_reason: None,
instructions: "Manage app".to_string(),
url: "https://example.test/notion".to_string(),
is_installed: true,
@@ -746,6 +764,7 @@ mod tests {
"suggestion_type": "install",
"connector_id": "connector_1",
"connector_name": "Notion",
"suggest_reason": "The user asked for workspace docs",
"connector_description": "Docs and notes",
"install_url": "https://example.test/notion",
})),
@@ -765,6 +784,7 @@ mod tests {
app_id: "connector_1".to_string(),
title: "Notion".to_string(),
description: Some("Docs and notes".to_string()),
suggest_reason: Some("The user asked for workspace docs".to_string()),
instructions: APP_LINK_INSTALL_INSTRUCTIONS.to_string(),
url: "https://example.test/notion".to_string(),
is_installed: false,
@@ -808,6 +828,7 @@ mod tests {
app_id: "connector_1".to_string(),
title: "Notion".to_string(),
description: Some("Docs and notes".to_string()),
suggest_reason: None,
instructions: APP_LINK_ENABLE_INSTRUCTIONS.to_string(),
url: "https://example.test/notion".to_string(),
is_installed: true,
@@ -835,6 +856,25 @@ mod tests {
);
}
#[test]
fn install_suggestion_with_reason_snapshot() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut params = base_params();
params.is_installed = false;
params.is_enabled = false;
params.suggest_reason = Some("The user asked to access their workspace docs.".to_string());
params.instructions = APP_LINK_INSTALL_INSTRUCTIONS.to_string();
params.suggestion_type = Some(ToolSuggestionType::Install);
let view = AppLinkView::new(params, tx);
let area = Rect::new(0, 0, 80, view.desired_height(80));
insta::assert_snapshot!(
"app_link_view_install_suggestion_with_reason",
render_snapshot(&view, area)
);
}
#[test]
fn install_confirmation_resolves_elicitation_after_refresh() {
let expected_thread_id = ThreadId::default();
@@ -0,0 +1,29 @@
---
source: tui/src/bottom_pane/app_link_view.rs
expression: "render_snapshot(&view, area)"
---
Buffer {
area: Rect { x: 0, y: 0, width: 80, height: 12 },
content: [
" ",
" Notion ",
" Reason: The user asked to access their workspace docs. ",
" ",
" Install this app in your browser, then reload Codex. ",
" Newly installed apps can take a few minutes to appear in /apps. ",
" After installed, use $ to insert this app into the prompt. ",
" ",
" ",
" › 1. Install on ChatGPT ",
" 2. Back ",
" Use tab / ↑ ↓ to move, enter to select, esc to close ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 8, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 9, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD,
x: 25, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
]
}