From fba13b1a8b146d6c5ab2e588c6d38ccbc00c8b70 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Wed, 11 Mar 2026 10:32:34 -0700 Subject: [PATCH] update --- .../consequential_tool_message_templates.json | 963 ++++++++++++++++++ codex-rs/core/src/mcp_tool_call.rs | 472 ++++++++- .../bottom_pane/request_user_input/layout.rs | 10 +- .../src/bottom_pane/request_user_input/mod.rs | 60 ++ 4 files changed, 1457 insertions(+), 48 deletions(-) create mode 100644 codex-rs/core/src/consequential_tool_message_templates.json diff --git a/codex-rs/core/src/consequential_tool_message_templates.json b/codex-rs/core/src/consequential_tool_message_templates.json new file mode 100644 index 0000000000..3f42b7a865 --- /dev/null +++ b/codex-rs/core/src/consequential_tool_message_templates.json @@ -0,0 +1,963 @@ +{ + "schema_version": 3, + "generated_from_path": "codex-rs/core/src/consequential_tools.json", + "templates": [ + { + "source_tool_index": 0, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "add_comment_to_issue", + "action": "add a comment to a pull request", + "template_params": [ + { + "name": "pr_number", + "label": "Pull request" + }, + { + "name": "repo_full_name", + "label": "Repository" + }, + { + "name": "comment", + "label": "Comment" + } + ] + }, + { + "source_tool_index": 1, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "add_reaction_to_issue_comment", + "action": "add a reaction to an issue comment", + "template_params": [ + { + "name": "reaction", + "label": "Reaction" + }, + { + "name": "comment_id", + "label": "Comment" + }, + { + "name": "repo_full_name", + "label": "Repository" + } + ] + }, + { + "source_tool_index": 2, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "add_reaction_to_pr", + "action": "add a reaction to a pull request", + "template_params": [ + { + "name": "reaction", + "label": "Reaction" + }, + { + "name": "pr_number", + "label": "Pull request" + }, + { + "name": "repo_full_name", + "label": "Repository" + } + ] + }, + { + "source_tool_index": 3, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "add_reaction_to_pr_review_comment", + "action": "add a reaction to a pull request review comment", + "template_params": [ + { + "name": "reaction", + "label": "Reaction" + }, + { + "name": "comment_id", + "label": "Comment" + }, + { + "name": "repo_full_name", + "label": "Repository" + } + ] + }, + { + "source_tool_index": 4, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "add_review_to_pr", + "action": "submit a pull request review", + "template_params": [ + { + "name": "action", + "label": "Action" + }, + { + "name": "pr_number", + "label": "Pull request" + }, + { + "name": "review", + "label": "Review" + } + ] + }, + { + "source_tool_index": 5, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "create_blob", + "action": "create a Git blob", + "template_params": [ + { + "name": "repository_full_name", + "label": "Repository" + }, + { + "name": "content", + "label": "Content" + } + ] + }, + { + "source_tool_index": 6, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "create_branch", + "action": "create a branch", + "template_params": [ + { + "name": "branch_name", + "label": "Branch" + }, + { + "name": "repository_full_name", + "label": "Repository" + } + ] + }, + { + "source_tool_index": 7, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "create_commit", + "action": "create a commit", + "template_params": [ + { + "name": "repository_full_name", + "label": "Repository" + }, + { + "name": "message", + "label": "Message" + } + ] + }, + { + "source_tool_index": 8, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "create_pull_request", + "action": "create a pull request", + "template_params": [ + { + "name": "title", + "label": "Title" + }, + { + "name": "head_branch", + "label": "Head branch" + }, + { + "name": "base_branch", + "label": "Base branch" + } + ] + }, + { + "source_tool_index": 9, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "create_tree", + "action": "create a Git tree", + "template_params": [ + { + "name": "repository_full_name", + "label": "Repository" + }, + { + "name": "tree_elements", + "label": "Changes" + } + ] + }, + { + "source_tool_index": 10, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "enable_auto_merge", + "action": "enable pull request auto-merge", + "template_params": [ + { + "name": "pr_number", + "label": "Pull request" + }, + { + "name": "repository_full_name", + "label": "Repository" + } + ] + }, + { + "source_tool_index": 11, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "label_pr", + "action": "add a label to a pull request", + "template_params": [ + { + "name": "label", + "label": "Label" + }, + { + "name": "pr_number", + "label": "Pull request" + }, + { + "name": "repository_full_name", + "label": "Repository" + } + ] + }, + { + "source_tool_index": 12, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "remove_reaction_from_issue_comment", + "action": "remove a reaction from an issue comment", + "template_params": [ + { + "name": "reaction_id", + "label": "Reaction" + }, + { + "name": "comment_id", + "label": "Comment" + }, + { + "name": "repo_full_name", + "label": "Repository" + } + ] + }, + { + "source_tool_index": 13, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "remove_reaction_from_pr", + "action": "remove a reaction from a pull request", + "template_params": [ + { + "name": "reaction_id", + "label": "Reaction" + }, + { + "name": "pr_number", + "label": "Pull request" + }, + { + "name": "repo_full_name", + "label": "Repository" + } + ] + }, + { + "source_tool_index": 14, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "remove_reaction_from_pr_review_comment", + "action": "remove a reaction from a pull request review comment", + "template_params": [ + { + "name": "reaction_id", + "label": "Reaction" + }, + { + "name": "comment_id", + "label": "Comment" + }, + { + "name": "repo_full_name", + "label": "Repository" + } + ] + }, + { + "source_tool_index": 15, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "reply_to_review_comment", + "action": "reply to a pull request review comment", + "template_params": [ + { + "name": "pr_number", + "label": "Pull request" + }, + { + "name": "repo_full_name", + "label": "Repository" + }, + { + "name": "comment", + "label": "Comment" + } + ] + }, + { + "source_tool_index": 16, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "update_issue_comment", + "action": "update an issue comment", + "template_params": [ + { + "name": "comment_id", + "label": "Comment" + }, + { + "name": "repo_full_name", + "label": "Repository" + }, + { + "name": "comment", + "label": "Comment" + } + ] + }, + { + "source_tool_index": 17, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "update_ref", + "action": "update a branch reference", + "template_params": [ + { + "name": "branch_name", + "label": "Branch" + }, + { + "name": "repository_full_name", + "label": "Repository" + }, + { + "name": "sha", + "label": "Commit" + } + ] + }, + { + "source_tool_index": 18, + "connector_id": "connector_76869538009648d5b282a4bb21c3d157", + "server_name": "codex_apps", + "tool_title": "update_review_comment", + "action": "update a pull request review comment", + "template_params": [ + { + "name": "comment_id", + "label": "Comment" + }, + { + "name": "repo_full_name", + "label": "Repository" + }, + { + "name": "comment", + "label": "Comment" + } + ] + }, + { + "source_tool_index": 19, + "connector_id": "connector_947e0d954944416db111db556030eea6", + "server_name": "codex_apps", + "tool_title": "create_event", + "action": "create a calendar event", + "template_params": [ + { + "name": "title", + "label": "Title" + }, + { + "name": "start_time", + "label": "Start" + }, + { + "name": "attendees", + "label": "Attendees" + } + ] + }, + { + "source_tool_index": 20, + "connector_id": "connector_947e0d954944416db111db556030eea6", + "server_name": "codex_apps", + "tool_title": "delete_event", + "action": "delete a calendar event", + "template_params": [ + { + "name": "event_id", + "label": "Event" + } + ] + }, + { + "source_tool_index": 21, + "connector_id": "connector_947e0d954944416db111db556030eea6", + "server_name": "codex_apps", + "tool_title": "respond_event", + "action": "respond to a calendar event", + "template_params": [ + { + "name": "response_status", + "label": "Response Status" + }, + { + "name": "event_id", + "label": "Event" + } + ] + }, + { + "source_tool_index": 22, + "connector_id": "connector_947e0d954944416db111db556030eea6", + "server_name": "codex_apps", + "tool_title": "update_event", + "action": "update a calendar event", + "template_params": [ + { + "name": "event_id", + "label": "Event" + } + ] + }, + { + "source_tool_index": 23, + "connector_id": "connector_9d7cfa34e6654a5f98d3387af34b2e1c", + "server_name": "codex_apps", + "tool_title": "batch_update", + "action": "apply spreadsheet updates", + "template_params": [ + { + "name": "spreadsheet_url", + "label": "Spreadsheet" + }, + { + "name": "requests", + "label": "Changes" + } + ] + }, + { + "source_tool_index": 24, + "connector_id": "connector_9d7cfa34e6654a5f98d3387af34b2e1c", + "server_name": "codex_apps", + "tool_title": "create_spreadsheet", + "action": "create a spreadsheet", + "template_params": [ + { + "name": "title", + "label": "Title" + } + ] + }, + { + "source_tool_index": 25, + "connector_id": "connector_9d7cfa34e6654a5f98d3387af34b2e1c", + "server_name": "codex_apps", + "tool_title": "duplicate_sheet_in_new_file", + "action": "copy a sheet into a new spreadsheet", + "template_params": [ + { + "name": "source_sheet_name", + "label": "Source Sheet Name" + }, + { + "name": "spreadsheet_url", + "label": "Spreadsheet" + }, + { + "name": "new_file_name", + "label": "New File Name" + } + ] + }, + { + "source_tool_index": 26, + "connector_id": "connector_6f1ec045b8fa4ced8738e32c7f74514b", + "server_name": "codex_apps", + "tool_title": "batch_update", + "action": "apply presentation updates", + "template_params": [ + { + "name": "presentation_url", + "label": "Presentation" + }, + { + "name": "requests", + "label": "Changes" + } + ] + }, + { + "source_tool_index": 27, + "connector_id": "connector_6f1ec045b8fa4ced8738e32c7f74514b", + "server_name": "codex_apps", + "tool_title": "create_presentation", + "action": "create a presentation", + "template_params": [ + { + "name": "title", + "label": "Title" + } + ] + }, + { + "source_tool_index": 28, + "connector_id": "connector_4964e3b22e3e427e9b4ae1acf2c1fa34", + "server_name": "codex_apps", + "tool_title": "batch_update", + "action": "apply document updates", + "template_params": [ + { + "name": "document_url", + "label": "Document" + }, + { + "name": "requests", + "label": "Changes" + } + ] + }, + { + "source_tool_index": 29, + "connector_id": "connector_4964e3b22e3e427e9b4ae1acf2c1fa34", + "server_name": "codex_apps", + "tool_title": "create_document", + "action": "create a document", + "template_params": [ + { + "name": "title", + "label": "Title" + } + ] + }, + { + "source_tool_index": 30, + "connector_id": "connector_5f3c8c41a1e54ad7a76272c89e2554fa", + "server_name": "codex_apps", + "tool_title": "copy_document", + "action": "copy a Google Drive file", + "template_params": [ + { + "name": "url", + "label": "URL" + } + ] + }, + { + "source_tool_index": 31, + "connector_id": "connector_5f3c8c41a1e54ad7a76272c89e2554fa", + "server_name": "codex_apps", + "tool_title": "share_document", + "action": "change file sharing", + "template_params": [ + { + "name": "url", + "label": "URL" + }, + { + "name": "permission", + "label": "Permission" + } + ] + }, + { + "source_tool_index": 32, + "connector_id": "asdk_app_69a1d78e929881919bba0dbda1f6436d", + "server_name": "codex_apps", + "tool_title": "slack_send_message", + "action": "send a message", + "template_params": [ + { + "name": "channel_id", + "label": "Conversation" + }, + { + "name": "message", + "label": "Message" + } + ] + }, + { + "source_tool_index": 33, + "connector_id": "asdk_app_69a1d78e929881919bba0dbda1f6436d", + "server_name": "codex_apps", + "tool_title": "slack_schedule_message", + "action": "schedule a message", + "template_params": [ + { + "name": "channel_id", + "label": "Conversation" + }, + { + "name": "post_at", + "label": "Send at" + }, + { + "name": "message", + "label": "Message" + } + ] + }, + { + "source_tool_index": 34, + "connector_id": "asdk_app_69a1d78e929881919bba0dbda1f6436d", + "server_name": "codex_apps", + "tool_title": "slack_create_canvas", + "action": "create a canvas", + "template_params": [ + { + "name": "title", + "label": "Title" + }, + { + "name": "content", + "label": "Content" + } + ] + }, + { + "source_tool_index": 35, + "connector_id": "asdk_app_69a1d78e929881919bba0dbda1f6436d", + "server_name": "codex_apps", + "tool_title": "slack_send_message_draft", + "action": "create a message draft", + "template_params": [ + { + "name": "channel_id", + "label": "Conversation" + }, + { + "name": "message", + "label": "Message" + } + ] + }, + { + "source_tool_index": 36, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "add_comment_to_issue", + "action": "add a comment to a Linear issue", + "template_params": [ + { + "name": "issue_id", + "label": "Issue" + }, + { + "name": "body", + "label": "Body" + } + ] + }, + { + "source_tool_index": 37, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "add_label_to_issue", + "action": "add a label to a Linear issue", + "template_params": [ + { + "name": "label_id", + "label": "Label" + }, + { + "name": "issue_id", + "label": "Issue" + } + ] + }, + { + "source_tool_index": 38, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "add_url_attachment_to_issue", + "action": "attach a link to a Linear issue", + "template_params": [ + { + "name": "url", + "label": "URL" + }, + { + "name": "issue_id", + "label": "Issue" + }, + { + "name": "title", + "label": "Title" + } + ] + }, + { + "source_tool_index": 39, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "assign_issue", + "action": "assign a Linear issue", + "template_params": [ + { + "name": "issue_id", + "label": "Issue" + }, + { + "name": "user_id", + "label": "User" + } + ] + }, + { + "source_tool_index": 40, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "create_issue", + "action": "create a Linear issue", + "template_params": [ + { + "name": "title", + "label": "Title" + }, + { + "name": "team_id", + "label": "Team" + } + ] + }, + { + "source_tool_index": 41, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "create_label", + "action": "create a Linear label", + "template_params": [ + { + "name": "label_name", + "label": "Label Name" + } + ] + }, + { + "source_tool_index": 42, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "create_project", + "action": "create a Linear project", + "template_params": [ + { + "name": "name", + "label": "Name" + }, + { + "name": "team_id", + "label": "Team" + } + ] + }, + { + "source_tool_index": 43, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "remove_label_from_issue", + "action": "remove a label from a Linear issue", + "template_params": [ + { + "name": "label_id", + "label": "Label" + }, + { + "name": "issue_id", + "label": "Issue" + } + ] + }, + { + "source_tool_index": 44, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "resolve_comment", + "action": "resolve a Linear comment", + "template_params": [ + { + "name": "comment_id", + "label": "Comment" + } + ] + }, + { + "source_tool_index": 45, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "set_issue_state", + "action": "change a Linear issue state", + "template_params": [ + { + "name": "issue_id", + "label": "Issue" + }, + { + "name": "state_id", + "label": "State" + } + ] + }, + { + "source_tool_index": 46, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "unassign_issue", + "action": "unassign a Linear issue", + "template_params": [ + { + "name": "issue_id", + "label": "Issue" + } + ] + }, + { + "source_tool_index": 47, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "update_issue", + "action": "update a Linear issue", + "template_params": [ + { + "name": "issue_id", + "label": "Issue" + }, + { + "name": "issue_update", + "label": "Changes" + } + ] + }, + { + "source_tool_index": 48, + "connector_id": "connector_686fad9b54914a35b75be6d06a0f6f31", + "server_name": "codex_apps", + "tool_title": "update_project", + "action": "update a Linear project", + "template_params": [ + { + "name": "project_id", + "label": "Project" + }, + { + "name": "update_fields", + "label": "Changes" + } + ] + }, + { + "source_tool_index": 49, + "connector_id": "connector_2128aebfecb84f64a069897515042a44", + "server_name": "codex_apps", + "tool_title": "apply_labels_to_emails", + "action": "apply label changes to Gmail messages", + "template_params": [] + }, + { + "source_tool_index": 50, + "connector_id": "connector_2128aebfecb84f64a069897515042a44", + "server_name": "codex_apps", + "tool_title": "batch_modify_email", + "action": "update Gmail message labels", + "template_params": [] + }, + { + "source_tool_index": 51, + "connector_id": "connector_2128aebfecb84f64a069897515042a44", + "server_name": "codex_apps", + "tool_title": "bulk_label_matching_emails", + "action": "label matching Gmail messages", + "template_params": [ + { + "name": "label_name", + "label": "Label Name" + }, + { + "name": "query", + "label": "Query" + } + ] + }, + { + "source_tool_index": 52, + "connector_id": "connector_2128aebfecb84f64a069897515042a44", + "server_name": "codex_apps", + "tool_title": "create_draft", + "action": "create an email draft", + "template_params": [ + { + "name": "to", + "label": "To" + }, + { + "name": "subject", + "label": "Subject" + }, + { + "name": "body", + "label": "Body" + } + ] + }, + { + "source_tool_index": 53, + "connector_id": "connector_2128aebfecb84f64a069897515042a44", + "server_name": "codex_apps", + "tool_title": "create_label", + "action": "create a Gmail label", + "template_params": [ + { + "name": "name", + "label": "Name" + } + ] + }, + { + "source_tool_index": 54, + "connector_id": "connector_2128aebfecb84f64a069897515042a44", + "server_name": "codex_apps", + "tool_title": "send_email", + "action": "send an email", + "template_params": [ + { + "name": "to", + "label": "To" + }, + { + "name": "subject", + "label": "Subject" + }, + { + "name": "body", + "label": "Body" + } + ] + } + ] +} diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 70421ae3dd..f3769ea619 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -44,9 +44,11 @@ use codex_protocol::request_user_input::RequestUserInputResponse; use codex_rmcp_client::ElicitationAction; use codex_rmcp_client::ElicitationResponse; use rmcp::model::ToolAnnotations; +use serde::Deserialize; use serde::Serialize; use std::path::Path; use std::sync::Arc; +use std::sync::LazyLock; use toml_edit::value; /// Handles the specified tool call dispatches the appropriate @@ -385,6 +387,40 @@ struct McpToolApprovalPromptOptions { allow_persistent_approval: bool, } +#[derive(Debug, Deserialize)] +struct ConsequentialToolMessageTemplateCatalog { + templates: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct ConsequentialToolMessageTemplate { + server_name: String, + connector_id: Option, + tool_title: String, + action: String, + template_params: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct ConsequentialToolMessageTemplateParam { + name: String, + label: String, +} + +static CONSEQUENTIAL_TOOL_MESSAGE_TEMPLATES: LazyLock< + Option>, +> = LazyLock::new(|| { + serde_json::from_str::(include_str!( + "consequential_tool_message_templates.json", + )) + .map(|catalog| catalog.templates) + .map_err(|err| { + error!("failed to parse bundled consequential tool message templates: {err}"); + err + }) + .ok() +}); + const MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX: &str = "mcp_tool_call_approval"; const MCP_TOOL_APPROVAL_ACCEPT: &str = "Allow"; const MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION: &str = "Allow for this session"; @@ -403,6 +439,11 @@ const MCP_TOOL_APPROVAL_CONNECTOR_DESCRIPTION_KEY: &str = "connector_description const MCP_TOOL_APPROVAL_TOOL_TITLE_KEY: &str = "tool_title"; const MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY: &str = "tool_description"; const MCP_TOOL_APPROVAL_TOOL_PARAMS_KEY: &str = "tool_params"; +const MCP_TOOL_APPROVAL_ACTOR_MAX_CHARS: usize = 48; +const MCP_TOOL_APPROVAL_ACTION_MAX_CHARS: usize = 72; +const MCP_TOOL_APPROVAL_DETAIL_VALUE_MAX_CHARS: usize = 80; +const MCP_TOOL_APPROVAL_DETAILS_MAX_CHARS: usize = 220; +const MCP_TOOL_APPROVAL_TRUNCATION_SUFFIX: &str = "..."; #[derive(Clone, Debug, PartialEq, Eq, Serialize)] struct McpToolApprovalKey { @@ -507,9 +548,8 @@ async fn maybe_request_mcp_tool_approval( question_id.clone(), &invocation.server, &invocation.tool, - metadata.and_then(|metadata| metadata.tool_title.as_deref()), - metadata.and_then(|metadata| metadata.connector_name.as_deref()), - annotations, + invocation.arguments.as_ref(), + metadata, prompt_options, ); question.question = @@ -738,34 +778,25 @@ fn build_mcp_tool_approval_question( question_id: String, server: &str, tool_name: &str, - tool_title: Option<&str>, - connector_name: Option<&str>, - annotations: Option<&ToolAnnotations>, + tool_params: Option<&serde_json::Value>, + metadata: Option<&McpToolApprovalMetadata>, prompt_options: McpToolApprovalPromptOptions, ) -> RequestUserInputQuestion { - let destructive = - annotations.and_then(|annotations| annotations.destructive_hint) == Some(true); - let open_world = annotations.and_then(|annotations| annotations.open_world_hint) == Some(true); - let reason = match (destructive, open_world) { - (true, true) => "may modify data and access external systems", - (true, false) => "may modify or delete data", - (false, true) => "may access external systems", - (false, false) => "may have side effects", - }; - - let tool_label = tool_title.unwrap_or(tool_name); - let app_label = connector_name - .map(|name| format!("The {name} app")) - .unwrap_or_else(|| { - if server == CODEX_APPS_MCP_SERVER_NAME { - "This app".to_string() - } else { - format!("The {server} MCP server") - } - }); - let question = format!( - "{app_label} wants to run the tool \"{tool_label}\", which {reason}. Allow this action?" - ); + let tool_title = metadata.and_then(|metadata| metadata.tool_title.as_deref()); + let connector_name = metadata.and_then(|metadata| metadata.connector_name.as_deref()); + let connector_id = metadata.and_then(|metadata| metadata.connector_id.as_deref()); + let annotations = metadata.and_then(|metadata| metadata.annotations.as_ref()); + let app_label = mcp_tool_approval_app_label(server, connector_name); + let question = build_templated_mcp_tool_approval_question_text( + server, + tool_name, + connector_id, + connector_name, + tool_params, + ) + .unwrap_or_else(|| { + build_legacy_mcp_tool_approval_question_text(&app_label, tool_name, tool_title, annotations) + }); let mut options = vec![RequestUserInputQuestionOption { label: MCP_TOOL_APPROVAL_ACCEPT.to_string(), @@ -790,7 +821,7 @@ fn build_mcp_tool_approval_question( RequestUserInputQuestion { id: question_id, - header: "Approve app tool call?".to_string(), + header: String::new(), question, is_other: false, is_secret: false, @@ -798,6 +829,187 @@ fn build_mcp_tool_approval_question( } } +fn build_templated_mcp_tool_approval_question_text( + server: &str, + tool_name: &str, + connector_id: Option<&str>, + connector_name: Option<&str>, + tool_params: Option<&serde_json::Value>, +) -> Option { + let actor = truncate_for_mcp_tool_approval_message( + &mcp_tool_approval_actor_label(server, connector_name), + MCP_TOOL_APPROVAL_ACTOR_MAX_CHARS, + ); + let tool_title = derive_consequential_tool_title(tool_name, connector_name); + let template = find_consequential_tool_message_template(server, connector_id, &tool_title)?; + let action = truncate_for_mcp_tool_approval_message( + &template.action, + MCP_TOOL_APPROVAL_ACTION_MAX_CHARS, + ); + let details = render_consequential_tool_details(template, tool_params)?; + + if details.is_empty() { + Some(format!("Allow {actor} to {action}?")) + } else { + Some(format!("Allow {actor} to {action}?\n\n{details}")) + } +} + +fn find_consequential_tool_message_template( + server: &str, + connector_id: Option<&str>, + tool_title: &str, +) -> Option<&'static ConsequentialToolMessageTemplate> { + CONSEQUENTIAL_TOOL_MESSAGE_TEMPLATES + .as_ref()? + .iter() + .find(|template| { + template.server_name == server + && template.connector_id.as_deref() == connector_id + && template.tool_title == tool_title + }) +} + +fn derive_consequential_tool_title(tool_name: &str, connector_name: Option<&str>) -> String { + if let Some(connector_name) = connector_name { + let connector_name = connector_name.to_lowercase(); + if let Some(suffix) = tool_name.strip_prefix(&connector_name) { + let tool_title = suffix.trim_start_matches('_'); + if !tool_title.is_empty() { + return tool_title.to_string(); + } + } + } + + tool_name.to_string() +} + +fn render_consequential_tool_details( + template: &ConsequentialToolMessageTemplate, + tool_params: Option<&serde_json::Value>, +) -> Option { + let tool_params = tool_params?.as_object()?; + let mut details = String::new(); + let mut remaining_chars = MCP_TOOL_APPROVAL_DETAILS_MAX_CHARS; + for param in &template.template_params { + let value = format_consequential_tool_message_value(tool_params.get(¶m.name)?)?; + let line = format!("{}: {value}", param.label); + let separator_chars = usize::from(!details.is_empty()); + if remaining_chars <= separator_chars { + break; + } + if !details.is_empty() { + details.push('\n'); + remaining_chars -= 1; + } + let line_chars = line.chars().count(); + if line_chars <= remaining_chars { + details.push_str(&line); + remaining_chars -= line_chars; + } else { + details.push_str(&truncate_for_mcp_tool_approval_message( + &line, + remaining_chars, + )); + break; + } + } + + Some(details) +} + +fn format_consequential_tool_message_value(value: &serde_json::Value) -> Option { + let formatted = match value { + serde_json::Value::Null => None, + serde_json::Value::Bool(value) => Some(value.to_string()), + serde_json::Value::Number(value) => Some(value.to_string()), + serde_json::Value::String(value) => { + let value = value.split_whitespace().collect::>().join(" "); + if value.is_empty() { None } else { Some(value) } + } + serde_json::Value::Array(values) => { + let values = values + .iter() + .map(format_consequential_tool_message_value) + .collect::>>()?; + if values.is_empty() { + None + } else { + Some(values.join(", ")) + } + } + serde_json::Value::Object(_) => serde_json::to_string(value).ok(), + }?; + + Some(truncate_for_mcp_tool_approval_message( + &formatted, + MCP_TOOL_APPROVAL_DETAIL_VALUE_MAX_CHARS, + )) +} + +fn truncate_for_mcp_tool_approval_message(text: &str, max_chars: usize) -> String { + let char_count = text.chars().count(); + if char_count <= max_chars { + return text.to_string(); + } + + if max_chars == 0 { + return String::new(); + } + + let suffix_chars = MCP_TOOL_APPROVAL_TRUNCATION_SUFFIX.chars().count(); + if max_chars <= suffix_chars { + return ".".repeat(max_chars); + } + + let prefix: String = text.chars().take(max_chars - suffix_chars).collect(); + format!("{prefix}{MCP_TOOL_APPROVAL_TRUNCATION_SUFFIX}") +} + +fn mcp_tool_approval_app_label(server: &str, connector_name: Option<&str>) -> String { + connector_name + .map(|name| format!("The {name} app")) + .unwrap_or_else(|| { + if server == CODEX_APPS_MCP_SERVER_NAME { + "This app".to_string() + } else { + format!("The {server} MCP server") + } + }) +} + +fn mcp_tool_approval_actor_label(server: &str, connector_name: Option<&str>) -> String { + connector_name.map(str::to_string).unwrap_or_else(|| { + if server == CODEX_APPS_MCP_SERVER_NAME { + "this app".to_string() + } else { + format!("the {server} MCP server") + } + }) +} + +fn build_legacy_mcp_tool_approval_question_text( + app_label: &str, + tool_name: &str, + tool_title: Option<&str>, + annotations: Option<&ToolAnnotations>, +) -> String { + let destructive = + annotations.and_then(|annotations| annotations.destructive_hint) == Some(true); + let open_world = annotations.and_then(|annotations| annotations.open_world_hint) == Some(true); + let reason = match (destructive, open_world) { + (true, true) => "may modify data and access external systems", + (true, false) => "may modify or delete data", + (false, true) => "may access external systems", + (false, false) => "may have side effects", + }; + + let tool_label = tool_title.unwrap_or(tool_name); + format!( + "{app_label} wants to run the tool \"{tool_label}\", which {reason}. Allow this action?" + ) +} + fn mcp_tool_approval_question_text(question: String, monitor_reason: Option<&str>) -> String { match monitor_reason.map(str::trim) { Some(reason) if !reason.is_empty() => { @@ -1282,17 +1494,18 @@ mod tests { #[test] fn custom_mcp_tool_question_mentions_server_name() { + let mut metadata = approval_metadata(None, None, None, Some("Run Action"), None); + metadata.annotations = Some(annotations(Some(false), Some(true), None)); let question = build_mcp_tool_approval_question( "q".to_string(), "custom_server", "run_action", - Some("Run Action"), None, - Some(&annotations(Some(false), Some(true), None)), + Some(&metadata), prompt_options(false, false), ); - assert_eq!(question.header, "Approve app tool call?"); + assert!(question.header.is_empty()); assert_eq!( question.question, "The custom_server MCP server wants to run the tool \"Run Action\", which may modify or delete data. Allow this action?" @@ -1309,13 +1522,14 @@ mod tests { #[test] fn codex_apps_tool_question_keeps_legacy_app_label() { + let mut metadata = approval_metadata(None, None, None, Some("Run Action"), None); + metadata.annotations = Some(annotations(Some(false), Some(true), None)); let question = build_mcp_tool_approval_question( "q".to_string(), CODEX_APPS_MCP_SERVER_NAME, "run_action", - Some("Run Action"), None, - Some(&annotations(Some(false), Some(true), None)), + Some(&metadata), prompt_options(true, true), ); @@ -1328,13 +1542,15 @@ mod tests { #[test] fn trusted_codex_apps_tool_question_offers_always_allow() { + let mut metadata = + approval_metadata(None, Some("Calendar"), None, Some("Run Action"), None); + metadata.annotations = Some(annotations(Some(false), Some(true), None)); let question = build_mcp_tool_approval_question( "q".to_string(), CODEX_APPS_MCP_SERVER_NAME, "run_action", - Some("Run Action"), - Some("Calendar"), - Some(&annotations(Some(false), Some(true), None)), + None, + Some(&metadata), prompt_options(true, true), ); let options = question.options.expect("options"); @@ -1370,13 +1586,15 @@ mod tests { tool_name: "run_action".to_string(), }; let persistent_key = session_key.clone(); + let mut metadata = + approval_metadata(None, Some("Calendar"), None, Some("Run Action"), None); + metadata.annotations = Some(annotations(Some(false), Some(true), None)); let question = build_mcp_tool_approval_question( "q".to_string(), CODEX_APPS_MCP_SERVER_NAME, "run_action", - Some("Run Action"), - Some("Calendar"), - Some(&annotations(Some(false), Some(true), None)), + None, + Some(&metadata), mcp_tool_approval_prompt_options(Some(&session_key), Some(&persistent_key), false), ); @@ -1397,13 +1615,14 @@ mod tests { #[test] fn custom_mcp_tool_question_offers_session_remember_without_always_allow() { + let mut metadata = approval_metadata(None, None, None, Some("Run Action"), None); + metadata.annotations = Some(annotations(Some(false), Some(true), None)); let question = build_mcp_tool_approval_question( "q".to_string(), "custom_server", "run_action", - Some("Run Action"), None, - Some(&annotations(Some(false), Some(true), None)), + Some(&metadata), prompt_options(true, false), ); @@ -1422,6 +1641,173 @@ mod tests { ); } + #[test] + fn derives_consequential_tool_title_from_connector_prefix() { + assert_eq!( + derive_consequential_tool_title("gmail_send_email", Some("Gmail")), + "send_email".to_string() + ); + assert_eq!( + derive_consequential_tool_title( + "slack (oai internal)_slack_send_message", + Some("Slack (OAI Internal)") + ), + "slack_send_message".to_string() + ); + assert_eq!( + derive_consequential_tool_title("custom_tool", None), + "custom_tool".to_string() + ); + } + + #[test] + fn truncate_for_mcp_tool_approval_message_preserves_utf8_boundaries() { + assert_eq!( + truncate_for_mcp_tool_approval_message("aé🙂zbcdef", 8), + "aé..." + ); + assert_eq!(truncate_for_mcp_tool_approval_message("aé🙂z", 4), "aé🙂z"); + } + + #[test] + fn consequential_tool_message_templates_use_at_most_three_params() { + let templates = CONSEQUENTIAL_TOOL_MESSAGE_TEMPLATES + .as_ref() + .expect("templates loaded"); + let oversized = templates + .iter() + .filter(|template| template.template_params.len() > 3) + .map(|template| template.tool_title.clone()) + .collect::>(); + + assert_eq!(oversized, Vec::::new()); + } + + #[test] + fn codex_apps_tool_question_uses_template_when_params_match() { + let template = CONSEQUENTIAL_TOOL_MESSAGE_TEMPLATES + .as_ref() + .expect("templates loaded") + .iter() + .find(|template| template.tool_title == "send_email") + .expect("send_email template"); + let mut metadata = approval_metadata( + template.connector_id.as_deref(), + Some("Gmail"), + None, + Some("Send Email"), + None, + ); + metadata.annotations = Some(annotations(Some(false), Some(true), Some(true))); + + let question = build_mcp_tool_approval_question( + "q".to_string(), + CODEX_APPS_MCP_SERVER_NAME, + "gmail_send_email", + Some(&serde_json::json!({ + "to": "alice@example.com", + "subject": "Quarterly update", + "body": "Status update body", + })), + Some(&metadata), + prompt_options(false, false), + ); + + assert_eq!( + question.question, + "Allow Gmail to send an email?\n\nTo: alice@example.com\nSubject: Quarterly update\nBody: Status update body" + ); + } + + #[test] + fn codex_apps_tool_question_falls_back_when_template_rendering_fails() { + let template = CONSEQUENTIAL_TOOL_MESSAGE_TEMPLATES + .as_ref() + .expect("templates loaded") + .iter() + .find(|template| template.tool_title == "send_email") + .expect("send_email template"); + let mut metadata = approval_metadata( + template.connector_id.as_deref(), + Some("Gmail"), + None, + Some("Send Email"), + None, + ); + metadata.annotations = Some(annotations(Some(false), Some(true), Some(true))); + + let question = build_mcp_tool_approval_question( + "q".to_string(), + CODEX_APPS_MCP_SERVER_NAME, + "gmail_send_email", + Some(&serde_json::json!({ + "subject": "Quarterly update", + "body": "Status update body", + })), + Some(&metadata), + prompt_options(false, false), + ); + + assert_eq!( + question.question, + "The Gmail app wants to run the tool \"Send Email\", which may modify data and access external systems. Allow this action?" + ); + } + + #[test] + fn codex_apps_tool_question_truncates_long_template_values() { + let template = CONSEQUENTIAL_TOOL_MESSAGE_TEMPLATES + .as_ref() + .expect("templates loaded") + .iter() + .find(|template| template.tool_title == "create_pull_request") + .expect("create_pull_request template"); + let mut metadata = approval_metadata( + template.connector_id.as_deref(), + Some("GitHub"), + None, + Some("Create Pull Request"), + None, + ); + metadata.annotations = Some(annotations(Some(false), Some(true), Some(true))); + + let title = "Launch readiness review ".repeat(10); + let head_branch = "feature/synchronize-release-notes-and-migration-guides-".repeat(4); + let base_branch = "release/2026-03-11-super-long-branch-name".repeat(3); + let repository_full_name = + "openai/very-long-repository-name-for-approval-prompt-tests".repeat(3); + let body = + "This pull request includes the release notes, migration guide, and rollout plan. " + .repeat(4); + + let question = build_mcp_tool_approval_question( + "q".to_string(), + CODEX_APPS_MCP_SERVER_NAME, + "github_create_pull_request", + Some(&serde_json::json!({ + "title": title, + "head_branch": head_branch, + "base_branch": base_branch, + "repository_full_name": repository_full_name, + "body": body, + })), + Some(&metadata), + prompt_options(false, false), + ); + + assert_eq!( + question.question, + "Allow GitHub to create a pull request?\n\nTitle: Launch readiness review Launch readiness review Launch readiness review La...\nHead branch: feature/synchronize-release-notes-and-migration-guides-feature/synchronize...\nBase branch: release/2026-03-11-..." + ); + assert!( + question.question.chars().count() + <= format!("Allow GitHub to create a pull request?\n\n") + .chars() + .count() + + MCP_TOOL_APPROVAL_DETAILS_MAX_CHARS + ); + } + #[test] fn custom_servers_keep_session_remember_without_persistent_approval() { let invocation = McpInvocation { diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs b/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs index 27d53229b6..c8261f031d 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs @@ -144,6 +144,11 @@ impl RequestUserInputOverlay { } if !notes_visible { + let mut spacer_after_question = 0; + if remaining > 0 { + spacer_after_question = 1; + remaining = remaining.saturating_sub(1); + } let mut spacer_after_options = 0; if remaining > footer_pref { spacer_after_options = 1; @@ -151,11 +156,6 @@ impl RequestUserInputOverlay { } let footer_lines = footer_pref.min(remaining); remaining = remaining.saturating_sub(footer_lines); - let mut spacer_after_question = 0; - if remaining > 0 { - spacer_after_question = 1; - remaining = remaining.saturating_sub(1); - } let grow_by = remaining.min(options.full.saturating_sub(options_height)); options_height = options_height.saturating_add(grow_by); return LayoutPlan { diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index 79b1229800..739a5ac227 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -1439,6 +1439,26 @@ mod tests { } } + fn question_with_long_approval_body(id: &str, header: &str) -> RequestUserInputQuestion { + RequestUserInputQuestion { + id: id.to_string(), + header: header.to_string(), + question: "Allow GitHub to create a pull request?\n\nTitle: Launch readiness review for the release branch.\nHead branch: feature/synchronize-release-notes-and-migration-guides\nBase branch: release/2026-03-11".to_string(), + is_other: false, + is_secret: false, + options: Some(vec![ + RequestUserInputQuestionOption { + label: "Allow".to_string(), + description: "Run the tool and continue.".to_string(), + }, + RequestUserInputQuestionOption { + label: "Cancel".to_string(), + description: "Cancel this tool call.".to_string(), + }, + ]), + } + } + fn question_without_options(id: &str, header: &str) -> RequestUserInputQuestion { RequestUserInputQuestion { id: id.to_string(), @@ -2505,6 +2525,46 @@ mod tests { ); } + #[test] + fn request_user_input_long_approval_body_snapshot() { + let (tx, _rx) = test_sender(); + let overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![question_with_long_approval_body("q1", "Approval")], + ), + tx, + true, + false, + false, + ); + let area = Rect::new(0, 0, 70, 12); + insta::assert_snapshot!( + "request_user_input_long_approval_body", + render_snapshot(&overlay, area) + ); + } + + #[test] + fn long_approval_body_keeps_spacer_after_question() { + let (tx, _rx) = test_sender(); + let overlay = RequestUserInputOverlay::new( + request_event( + "turn-1", + vec![question_with_long_approval_body("q1", "Approval")], + ), + tx, + true, + false, + false, + ); + let content_area = menu_surface_inset(Rect::new(0, 0, 70, 12)); + let sections = overlay.layout_sections(content_area); + let question_bottom = sections.question_area.y + sections.question_area.height; + + assert_eq!(sections.options_area.y.saturating_sub(question_bottom), 1); + } + #[test] fn layout_allocates_all_wrapped_options_when_space_allows() { let (tx, _rx) = test_sender();