mirror of
https://github.com/openai/codex.git
synced 2026-03-17 03:16:39 +03:00
Compare commits
2 Commits
starr/exec
...
dev/mzeng/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0f8f87960 | ||
|
|
e5995b1f70 |
@@ -1986,6 +1986,25 @@ impl Session {
|
||||
state.clear_connector_selection();
|
||||
}
|
||||
|
||||
pub(crate) async fn record_slack_channel_name(
|
||||
&self,
|
||||
connector_id: Option<&str>,
|
||||
channel_id: String,
|
||||
channel_name: String,
|
||||
) {
|
||||
let mut state = self.state.lock().await;
|
||||
state.record_slack_channel_name(connector_id, channel_id, channel_name);
|
||||
}
|
||||
|
||||
pub(crate) async fn slack_channel_name(
|
||||
&self,
|
||||
connector_id: Option<&str>,
|
||||
channel_id: &str,
|
||||
) -> Option<String> {
|
||||
let state = self.state.lock().await;
|
||||
state.slack_channel_name(connector_id, channel_id)
|
||||
}
|
||||
|
||||
async fn record_initial_history(&self, conversation_history: InitialHistory) {
|
||||
let turn_context = self.new_default_turn().await;
|
||||
let is_subagent = {
|
||||
|
||||
@@ -591,6 +591,10 @@
|
||||
"server_name": "codex_apps",
|
||||
"tool_title": "slack_send_message",
|
||||
"template_params": [
|
||||
{
|
||||
"name": "to",
|
||||
"label": "To"
|
||||
},
|
||||
{
|
||||
"name": "channel_id",
|
||||
"label": "Conversation"
|
||||
|
||||
@@ -69,6 +69,7 @@ mod sandbox_tags;
|
||||
pub mod sandboxing;
|
||||
mod session_prefix;
|
||||
mod shell_detect;
|
||||
mod slack_channel_names;
|
||||
mod stream_events_utils;
|
||||
pub mod test_support;
|
||||
mod text_encoding;
|
||||
|
||||
@@ -1220,7 +1220,7 @@ fn normalize_codex_apps_tool_title(
|
||||
value.to_string()
|
||||
}
|
||||
|
||||
fn normalize_codex_apps_tool_name(
|
||||
pub(crate) fn normalize_codex_apps_tool_name(
|
||||
server_name: &str,
|
||||
tool_name: &str,
|
||||
connector_id: Option<&str>,
|
||||
|
||||
@@ -310,6 +310,48 @@ mod tests {
|
||||
assert_eq!(CONSEQUENTIAL_TOOL_MESSAGE_TEMPLATES.is_some(), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_slack_send_message_template_renders_to_before_conversation() {
|
||||
let rendered = render_mcp_tool_approval_template(
|
||||
"codex_apps",
|
||||
Some("asdk_app_69a1d78e929881919bba0dbda1f6436d"),
|
||||
Some("Slack"),
|
||||
Some("slack_send_message"),
|
||||
Some(&json!({
|
||||
"channel_id": "U123",
|
||||
"message": "hello",
|
||||
"to": "@mzeng",
|
||||
})),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
rendered,
|
||||
Some(RenderedMcpToolApprovalTemplate {
|
||||
question: "Allow Slack to send a message?".to_string(),
|
||||
elicitation_message: "Allow Slack to send a message?".to_string(),
|
||||
tool_params: Some(json!({
|
||||
"To": "@mzeng",
|
||||
"Conversation": "U123",
|
||||
"Message": "hello",
|
||||
})),
|
||||
tool_params_display: vec![
|
||||
RenderedMcpToolApprovalParam {
|
||||
name: "To".to_string(),
|
||||
value: json!("@mzeng"),
|
||||
},
|
||||
RenderedMcpToolApprovalParam {
|
||||
name: "Conversation".to_string(),
|
||||
value: json!("U123"),
|
||||
},
|
||||
RenderedMcpToolApprovalParam {
|
||||
name: "Message".to_string(),
|
||||
value: json!("hello"),
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_literal_template_without_connector_substitution() {
|
||||
let templates = vec![ConsequentialToolMessageTemplate {
|
||||
|
||||
@@ -26,12 +26,17 @@ use crate::guardian::guardian_approval_request_to_json;
|
||||
use crate::guardian::review_approval_request;
|
||||
use crate::guardian::routes_approval_to_guardian;
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use crate::mcp_connection_manager::ToolInfo;
|
||||
use crate::mcp_connection_manager::normalize_codex_apps_tool_name;
|
||||
use crate::mcp_tool_approval_templates::RenderedMcpToolApprovalParam;
|
||||
use crate::mcp_tool_approval_templates::render_mcp_tool_approval_template;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::McpInvocation;
|
||||
use crate::protocol::McpToolCallBeginEvent;
|
||||
use crate::protocol::McpToolCallEndEvent;
|
||||
use crate::slack_channel_names::slack_channel_name_from_tool_result;
|
||||
use crate::slack_channel_names::slack_send_message_channel_id;
|
||||
use crate::slack_channel_names::translated_slack_send_message_tool_params;
|
||||
use crate::state_db;
|
||||
use codex_protocol::mcp::CallToolResult;
|
||||
use codex_protocol::openai_models::InputModality;
|
||||
@@ -170,6 +175,12 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
tool_call_end_event.clone(),
|
||||
)
|
||||
.await;
|
||||
maybe_record_slack_channel_name_from_tool_result(
|
||||
sess.as_ref(),
|
||||
metadata.as_ref(),
|
||||
&result,
|
||||
)
|
||||
.await;
|
||||
maybe_track_codex_app_used(
|
||||
sess.as_ref(),
|
||||
turn_context.as_ref(),
|
||||
@@ -257,6 +268,8 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
tool_call_end_event.clone(),
|
||||
)
|
||||
.await;
|
||||
maybe_record_slack_channel_name_from_tool_result(sess.as_ref(), metadata.as_ref(), &result)
|
||||
.await;
|
||||
maybe_track_codex_app_used(sess.as_ref(), turn_context.as_ref(), &server, &tool_name).await;
|
||||
|
||||
let status = if result.is_ok() { "ok" } else { "error" };
|
||||
@@ -516,17 +529,19 @@ async fn maybe_request_mcp_tool_approval(
|
||||
tool_call_mcp_elicitation_enabled,
|
||||
);
|
||||
let question_id = format!("{MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX}_{call_id}");
|
||||
let approval_tool_params =
|
||||
build_mcp_tool_approval_tool_params(sess.as_ref(), invocation, metadata).await;
|
||||
let rendered_template = render_mcp_tool_approval_template(
|
||||
&invocation.server,
|
||||
metadata.and_then(|metadata| metadata.connector_id.as_deref()),
|
||||
metadata.and_then(|metadata| metadata.connector_name.as_deref()),
|
||||
metadata.and_then(|metadata| metadata.tool_title.as_deref()),
|
||||
invocation.arguments.as_ref(),
|
||||
approval_tool_params.as_ref(),
|
||||
);
|
||||
let tool_params_display = rendered_template
|
||||
.as_ref()
|
||||
.map(|rendered_template| rendered_template.tool_params_display.clone())
|
||||
.or_else(|| build_mcp_tool_approval_display_params(invocation.arguments.as_ref()));
|
||||
.or_else(|| build_mcp_tool_approval_display_params(approval_tool_params.as_ref()));
|
||||
let mut question = build_mcp_tool_approval_question(
|
||||
question_id.clone(),
|
||||
&invocation.server,
|
||||
@@ -552,7 +567,7 @@ async fn maybe_request_mcp_tool_approval(
|
||||
tool_params: rendered_template
|
||||
.as_ref()
|
||||
.and_then(|rendered_template| rendered_template.tool_params.as_ref())
|
||||
.or(invocation.arguments.as_ref()),
|
||||
.or(approval_tool_params.as_ref()),
|
||||
tool_params_display: tool_params_display.as_deref(),
|
||||
question,
|
||||
message_override: rendered_template.as_ref().and_then(|rendered_template| {
|
||||
@@ -676,6 +691,54 @@ fn build_guardian_mcp_tool_review_request(
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_mcp_tool_approval_tool_params(
|
||||
sess: &Session,
|
||||
invocation: &McpInvocation,
|
||||
metadata: Option<&McpToolApprovalMetadata>,
|
||||
) -> Option<serde_json::Value> {
|
||||
let tool_title = metadata.and_then(|metadata| metadata.tool_title.as_deref());
|
||||
let connector_id = metadata.and_then(|metadata| metadata.connector_id.as_deref());
|
||||
let channel_name = match slack_send_message_channel_id(
|
||||
connector_id,
|
||||
tool_title,
|
||||
invocation.arguments.as_ref(),
|
||||
) {
|
||||
Some(channel_id) => sess.slack_channel_name(connector_id, channel_id).await,
|
||||
None => None,
|
||||
};
|
||||
|
||||
translated_slack_send_message_tool_params(
|
||||
connector_id,
|
||||
tool_title,
|
||||
invocation.arguments.as_ref(),
|
||||
channel_name.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn maybe_record_slack_channel_name_from_tool_result(
|
||||
sess: &Session,
|
||||
metadata: Option<&McpToolApprovalMetadata>,
|
||||
result: &Result<CallToolResult, String>,
|
||||
) {
|
||||
let Ok(result) = result else {
|
||||
return;
|
||||
};
|
||||
let Some(channel_name) = slack_channel_name_from_tool_result(
|
||||
metadata.and_then(|metadata| metadata.connector_id.as_deref()),
|
||||
metadata.and_then(|metadata| metadata.tool_title.as_deref()),
|
||||
result,
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
|
||||
sess.record_slack_channel_name(
|
||||
metadata.and_then(|metadata| metadata.connector_id.as_deref()),
|
||||
channel_name.channel_id,
|
||||
channel_name.channel_name,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
fn mcp_tool_approval_decision_from_guardian(decision: ReviewDecision) -> McpToolApprovalDecision {
|
||||
match decision {
|
||||
ReviewDecision::Approved
|
||||
@@ -708,9 +771,7 @@ async fn lookup_mcp_tool_metadata(
|
||||
.list_all_tools()
|
||||
.await;
|
||||
|
||||
let tool_info = tools
|
||||
.into_values()
|
||||
.find(|tool_info| tool_info.server_name == server && tool_info.tool.name == tool_name)?;
|
||||
let tool_info = find_mcp_tool_info(&tools, server, tool_name)?.clone();
|
||||
let connector_description = if server == CODEX_APPS_MCP_SERVER_NAME {
|
||||
let connectors = match connectors::list_cached_accessible_connectors_from_mcp_tools(
|
||||
turn_context.config.as_ref(),
|
||||
@@ -758,15 +819,33 @@ async fn lookup_mcp_app_usage_metadata(
|
||||
.list_all_tools()
|
||||
.await;
|
||||
|
||||
tools.into_values().find_map(|tool_info| {
|
||||
if tool_info.server_name == server && tool_info.tool.name == tool_name {
|
||||
Some(McpAppUsageMetadata {
|
||||
connector_id: tool_info.connector_id,
|
||||
app_name: tool_info.connector_name,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
let tool_info = find_mcp_tool_info(&tools, server, tool_name)?;
|
||||
|
||||
Some(McpAppUsageMetadata {
|
||||
connector_id: tool_info.connector_id.clone(),
|
||||
app_name: tool_info.connector_name.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn find_mcp_tool_info<'a>(
|
||||
tools: &'a std::collections::HashMap<String, ToolInfo>,
|
||||
server: &str,
|
||||
raw_tool_name: &str,
|
||||
) -> Option<&'a ToolInfo> {
|
||||
tools.values().find(|tool_info| {
|
||||
if tool_info.server_name != server {
|
||||
return false;
|
||||
}
|
||||
|
||||
tool_info.tool.name == raw_tool_name
|
||||
|| tool_info.tool_name == raw_tool_name
|
||||
|| (server == CODEX_APPS_MCP_SERVER_NAME
|
||||
&& normalize_codex_apps_tool_name(
|
||||
server,
|
||||
raw_tool_name,
|
||||
tool_info.connector_id.as_deref(),
|
||||
tool_info.connector_name.as_deref(),
|
||||
) == tool_info.tool_name)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1242,6 +1321,7 @@ mod tests {
|
||||
use crate::config::types::AppToolConfig;
|
||||
use crate::config::types::AppToolsConfig;
|
||||
use crate::config::types::AppsConfigToml;
|
||||
use crate::slack_channel_names::SLACK_CONNECTOR_ID;
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde::Deserialize;
|
||||
@@ -1280,6 +1360,39 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn test_codex_app_tool(
|
||||
qualified_name: &str,
|
||||
raw_tool_name: &str,
|
||||
normalized_tool_name: &str,
|
||||
tool_namespace: &str,
|
||||
connector_id: &str,
|
||||
connector_name: &str,
|
||||
) -> (String, ToolInfo) {
|
||||
(
|
||||
qualified_name.to_string(),
|
||||
ToolInfo {
|
||||
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
tool_name: normalized_tool_name.to_string(),
|
||||
tool_namespace: tool_namespace.to_string(),
|
||||
tool: rmcp::model::Tool {
|
||||
name: raw_tool_name.to_string().into(),
|
||||
title: None,
|
||||
description: None,
|
||||
input_schema: Arc::new(rmcp::model::JsonObject::default()),
|
||||
output_schema: None,
|
||||
annotations: None,
|
||||
execution: None,
|
||||
icons: None,
|
||||
meta: None,
|
||||
},
|
||||
connector_id: Some(connector_id.to_string()),
|
||||
connector_name: Some(connector_name.to_string()),
|
||||
plugin_display_names: Vec::new(),
|
||||
connector_description: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn prompt_options(
|
||||
allow_session_remember: bool,
|
||||
allow_persistent_approval: bool,
|
||||
@@ -1326,6 +1439,40 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_mcp_tool_info_falls_back_to_connector_aware_tool_name_matching() {
|
||||
let tools = HashMap::from([test_codex_app_tool(
|
||||
"mcp__codex_apps__gmail-batch-read-email",
|
||||
"gmail-batch-read-email",
|
||||
"-batch-read-email",
|
||||
"mcp__codex_apps__gmail",
|
||||
"connector_gmail_456",
|
||||
"Gmail",
|
||||
)]);
|
||||
|
||||
let tool = find_mcp_tool_info(
|
||||
&tools,
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
"connector-gmail-456-batch-read-email",
|
||||
)
|
||||
.expect("tool");
|
||||
|
||||
assert_eq!(
|
||||
(
|
||||
tool.connector_id.as_deref(),
|
||||
tool.connector_name.as_deref(),
|
||||
tool.tool.name.as_ref(),
|
||||
tool.tool_name.as_str(),
|
||||
),
|
||||
(
|
||||
Some("connector_gmail_456"),
|
||||
Some("Gmail"),
|
||||
"gmail-batch-read-email",
|
||||
"-batch-read-email",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_question_text_prepends_safety_reason() {
|
||||
assert_eq!(
|
||||
@@ -1408,6 +1555,113 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slack_profile_tool_result_records_channel_name_with_global_fallback() {
|
||||
let (session, _turn_context) = make_session_and_context().await;
|
||||
let metadata = approval_metadata(
|
||||
Some(SLACK_CONNECTOR_ID),
|
||||
Some("Slack Codex App"),
|
||||
None,
|
||||
Some("get_profile"),
|
||||
None,
|
||||
);
|
||||
let result = Ok(CallToolResult {
|
||||
content: Vec::new(),
|
||||
structured_content: Some(serde_json::json!({
|
||||
"result": {
|
||||
"id": "U123",
|
||||
"name": "Mason Zeng",
|
||||
"nickname": "mzeng",
|
||||
}
|
||||
})),
|
||||
is_error: Some(false),
|
||||
meta: None,
|
||||
});
|
||||
|
||||
maybe_record_slack_channel_name_from_tool_result(&session, Some(&metadata), &result).await;
|
||||
|
||||
assert_eq!(
|
||||
session
|
||||
.slack_channel_name(Some(SLACK_CONNECTOR_ID), "U123")
|
||||
.await,
|
||||
Some("@mzeng".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
session
|
||||
.slack_channel_name(Some("connector_other"), "U123")
|
||||
.await,
|
||||
Some("@mzeng".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slack_search_users_result_records_channel_name() {
|
||||
let (session, _turn_context) = make_session_and_context().await;
|
||||
let metadata = approval_metadata(
|
||||
Some(SLACK_CONNECTOR_ID),
|
||||
Some("Slack Codex App"),
|
||||
None,
|
||||
Some("slack_search_users"),
|
||||
None,
|
||||
);
|
||||
let result = Ok(CallToolResult {
|
||||
content: Vec::new(),
|
||||
structured_content: Some(serde_json::json!({
|
||||
"results": "# Search Results for: mzeng@openai.com\n\n## Users (1 results)\n### Result 1 of 1\nName: Matthew Zeng\nUser ID: U07B9LBRPST\nTitle: Codexing agent\nEmail: mzeng@openai.com",
|
||||
})),
|
||||
is_error: Some(false),
|
||||
meta: None,
|
||||
});
|
||||
|
||||
maybe_record_slack_channel_name_from_tool_result(&session, Some(&metadata), &result).await;
|
||||
|
||||
assert_eq!(
|
||||
session
|
||||
.slack_channel_name(Some("connector_other"), "U07B9LBRPST")
|
||||
.await,
|
||||
Some("Matthew Zeng".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slack_send_message_approval_tool_params_include_translated_to_field() {
|
||||
let (session, _turn_context) = make_session_and_context().await;
|
||||
session
|
||||
.record_slack_channel_name(
|
||||
Some(SLACK_CONNECTOR_ID),
|
||||
"U123".to_string(),
|
||||
"@mzeng".to_string(),
|
||||
)
|
||||
.await;
|
||||
let invocation = McpInvocation {
|
||||
server: CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
tool: "slack_slack_send_message".to_string(),
|
||||
arguments: Some(serde_json::json!({
|
||||
"channel_id": "U123",
|
||||
"message": "hi",
|
||||
})),
|
||||
};
|
||||
let metadata = approval_metadata(
|
||||
Some(SLACK_CONNECTOR_ID),
|
||||
Some("Slack"),
|
||||
None,
|
||||
Some("slack_send_message"),
|
||||
None,
|
||||
);
|
||||
|
||||
let tool_params =
|
||||
build_mcp_tool_approval_tool_params(&session, &invocation, Some(&metadata)).await;
|
||||
|
||||
assert_eq!(
|
||||
tool_params,
|
||||
Some(serde_json::json!({
|
||||
"channel_id": "U123",
|
||||
"message": "hi",
|
||||
"to": "@mzeng",
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_mcp_tool_question_mentions_server_name() {
|
||||
let question = build_mcp_tool_approval_question(
|
||||
|
||||
450
codex-rs/core/src/slack_channel_names.rs
Normal file
450
codex-rs/core/src/slack_channel_names.rs
Normal file
@@ -0,0 +1,450 @@
|
||||
use codex_protocol::mcp::CallToolResult;
|
||||
use serde_json::Map;
|
||||
use serde_json::Value;
|
||||
|
||||
const CHANNEL_ID_FIELD_NAME: &str = "channel_id";
|
||||
const ID_FIELD_NAME: &str = "id";
|
||||
const NAME_FIELD_NAME: &str = "name";
|
||||
const NICKNAME_FIELD_NAME: &str = "nickname";
|
||||
const RESULT_FIELD_NAME: &str = "result";
|
||||
const RESULTS_FIELD_NAME: &str = "results";
|
||||
const TO_FIELD_NAME: &str = "to";
|
||||
|
||||
pub(crate) const SLACK_CONNECTOR_ID: &str = "asdk_app_69a1d78e929881919bba0dbda1f6436d";
|
||||
|
||||
const SLACK_GET_PROFILE_TOOL_TITLE: &str = "get_profile";
|
||||
const SLACK_READ_USER_PROFILE_TOOL_TITLE: &str = "slack_read_user_profile";
|
||||
const SLACK_SEARCH_USERS_TOOL_TITLE: &str = "slack_search_users";
|
||||
const SLACK_SEND_MESSAGE_TOOL_TITLE: &str = "slack_send_message";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct SlackChannelName {
|
||||
pub(crate) channel_id: String,
|
||||
pub(crate) channel_name: String,
|
||||
}
|
||||
|
||||
pub(crate) fn slack_channel_name_from_tool_result(
|
||||
connector_id: Option<&str>,
|
||||
tool_title: Option<&str>,
|
||||
result: &CallToolResult,
|
||||
) -> Option<SlackChannelName> {
|
||||
if !is_slack_channel_lookup_tool(connector_id, tool_title) {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(channel_name) = result
|
||||
.structured_content
|
||||
.as_ref()
|
||||
.filter(|value| !value.is_null())
|
||||
.and_then(slack_channel_name_from_payload)
|
||||
{
|
||||
return Some(channel_name);
|
||||
}
|
||||
|
||||
let parsed_text_payload = parse_payload_from_text_content(&result.content);
|
||||
if let Some(channel_name) = parsed_text_payload
|
||||
.as_ref()
|
||||
.and_then(slack_channel_name_from_payload)
|
||||
{
|
||||
return Some(channel_name);
|
||||
}
|
||||
|
||||
raw_text_content(&result.content).and_then(parse_slack_channel_name_from_text)
|
||||
}
|
||||
|
||||
pub(crate) fn slack_send_message_channel_id<'a>(
|
||||
connector_id: Option<&str>,
|
||||
tool_title: Option<&str>,
|
||||
tool_params: Option<&'a Value>,
|
||||
) -> Option<&'a str> {
|
||||
if !is_slack_send_message_tool(connector_id, tool_title) {
|
||||
return None;
|
||||
}
|
||||
|
||||
tool_params?
|
||||
.as_object()?
|
||||
.get(CHANNEL_ID_FIELD_NAME)
|
||||
.and_then(nonempty_string)
|
||||
}
|
||||
|
||||
pub(crate) fn translated_slack_send_message_tool_params(
|
||||
connector_id: Option<&str>,
|
||||
tool_title: Option<&str>,
|
||||
tool_params: Option<&Value>,
|
||||
channel_name: Option<&str>,
|
||||
) -> Option<Value> {
|
||||
let tool_params = tool_params?;
|
||||
if !is_slack_send_message_tool(connector_id, tool_title) {
|
||||
return Some(tool_params.clone());
|
||||
}
|
||||
|
||||
let Some(channel_name) = channel_name.map(str::trim).filter(|name| !name.is_empty()) else {
|
||||
return Some(tool_params.clone());
|
||||
};
|
||||
let Value::Object(tool_params) = tool_params else {
|
||||
return Some(tool_params.clone());
|
||||
};
|
||||
|
||||
let mut translated = tool_params.clone();
|
||||
translated.insert(
|
||||
TO_FIELD_NAME.to_string(),
|
||||
Value::String(channel_name.to_string()),
|
||||
);
|
||||
Some(Value::Object(translated))
|
||||
}
|
||||
|
||||
fn is_slack_channel_lookup_tool(connector_id: Option<&str>, tool_title: Option<&str>) -> bool {
|
||||
is_slack_connector(connector_id)
|
||||
&& matches!(
|
||||
tool_title.map(str::trim),
|
||||
Some(SLACK_GET_PROFILE_TOOL_TITLE)
|
||||
| Some(SLACK_READ_USER_PROFILE_TOOL_TITLE)
|
||||
| Some(SLACK_SEARCH_USERS_TOOL_TITLE)
|
||||
)
|
||||
}
|
||||
|
||||
fn is_slack_send_message_tool(connector_id: Option<&str>, tool_title: Option<&str>) -> bool {
|
||||
is_slack_connector(connector_id)
|
||||
&& matches!(
|
||||
tool_title.map(str::trim),
|
||||
Some(SLACK_SEND_MESSAGE_TOOL_TITLE)
|
||||
)
|
||||
}
|
||||
|
||||
fn is_slack_connector(connector_id: Option<&str>) -> bool {
|
||||
connector_id
|
||||
.map(str::trim)
|
||||
.is_some_and(|connector_id| connector_id == SLACK_CONNECTOR_ID)
|
||||
}
|
||||
|
||||
fn parse_payload_from_text_content(content: &[Value]) -> Option<Value> {
|
||||
let text = raw_text_content(content)?;
|
||||
serde_json::from_str(text).ok()
|
||||
}
|
||||
|
||||
fn raw_text_content(content: &[Value]) -> Option<&str> {
|
||||
let [content_block] = content else {
|
||||
return None;
|
||||
};
|
||||
let content_block = content_block.as_object()?;
|
||||
if content_block.get("type").and_then(Value::as_str) != Some("text") {
|
||||
return None;
|
||||
}
|
||||
|
||||
content_block.get("text").and_then(nonempty_string)
|
||||
}
|
||||
|
||||
fn slack_channel_name_from_payload(payload: &Value) -> Option<SlackChannelName> {
|
||||
let payload = payload.as_object()?;
|
||||
let result = payload
|
||||
.get(RESULT_FIELD_NAME)
|
||||
.or_else(|| payload.get(RESULTS_FIELD_NAME));
|
||||
if let Some(result_text) = result.and_then(nonempty_string) {
|
||||
return parse_slack_channel_name_from_text(result_text);
|
||||
}
|
||||
if let Some(result_object) = result.and_then(Value::as_object) {
|
||||
return slack_channel_name_from_profile_object(result_object);
|
||||
}
|
||||
|
||||
slack_channel_name_from_profile_object(payload)
|
||||
}
|
||||
|
||||
fn slack_channel_name_from_profile_object(
|
||||
profile: &Map<String, Value>,
|
||||
) -> Option<SlackChannelName> {
|
||||
let channel_id = profile
|
||||
.get(ID_FIELD_NAME)
|
||||
.and_then(nonempty_string)?
|
||||
.to_string();
|
||||
let channel_name = readable_slack_channel_name(profile)?;
|
||||
|
||||
Some(SlackChannelName {
|
||||
channel_id,
|
||||
channel_name,
|
||||
})
|
||||
}
|
||||
|
||||
fn readable_slack_channel_name(profile: &Map<String, Value>) -> Option<String> {
|
||||
if let Some(nickname) = profile.get(NICKNAME_FIELD_NAME).and_then(nonempty_string) {
|
||||
return Some(format_slack_handle(nickname));
|
||||
}
|
||||
|
||||
profile
|
||||
.get(NAME_FIELD_NAME)
|
||||
.and_then(nonempty_string)
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
fn format_slack_handle(handle: &str) -> String {
|
||||
if handle.starts_with('@') {
|
||||
handle.to_string()
|
||||
} else {
|
||||
format!("@{handle}")
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_slack_channel_name_from_text(text: &str) -> Option<SlackChannelName> {
|
||||
let lines = text
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
let channel_id = labeled_multiline_value(&lines, "User ID")?;
|
||||
let channel_name =
|
||||
handle_from_profile_lines(&lines).or_else(|| labeled_multiline_value(&lines, "Name"))?;
|
||||
|
||||
Some(SlackChannelName {
|
||||
channel_id,
|
||||
channel_name,
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_from_profile_lines(lines: &[&str]) -> Option<String> {
|
||||
let header = *lines.first()?;
|
||||
let open_index = header.rfind('(')?;
|
||||
let close_index = header.rfind(')')?;
|
||||
if close_index <= open_index {
|
||||
return None;
|
||||
}
|
||||
|
||||
let candidate = header[open_index + 1..close_index].trim();
|
||||
is_plausible_slack_handle(candidate).then(|| format_slack_handle(candidate))
|
||||
}
|
||||
|
||||
fn is_plausible_slack_handle(candidate: &str) -> bool {
|
||||
!candidate.is_empty()
|
||||
&& candidate
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
|
||||
}
|
||||
|
||||
fn labeled_multiline_value(lines: &[&str], label: &str) -> Option<String> {
|
||||
let prefix = format!("{label}:");
|
||||
for (index, line) in lines.iter().enumerate() {
|
||||
if let Some(value) = line.strip_prefix(&prefix) {
|
||||
let mut combined = value.trim().to_string();
|
||||
let mut next_index = index + 1;
|
||||
while next_index < lines.len() && !looks_like_field_label(lines[next_index]) {
|
||||
if !combined.is_empty() {
|
||||
combined.push(' ');
|
||||
}
|
||||
combined.push_str(lines[next_index]);
|
||||
next_index += 1;
|
||||
}
|
||||
|
||||
return Some(combined);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn looks_like_field_label(line: &str) -> bool {
|
||||
let Some((label, _)) = line.split_once(':') else {
|
||||
return false;
|
||||
};
|
||||
!label.trim().is_empty()
|
||||
&& label
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphabetic() || ch == ' ' || ch == '#')
|
||||
}
|
||||
|
||||
fn nonempty_string(value: &Value) -> Option<&str> {
|
||||
value
|
||||
.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn extracts_slack_channel_name_from_profile_result() {
|
||||
let result = CallToolResult {
|
||||
content: Vec::new(),
|
||||
structured_content: Some(json!({
|
||||
"result": {
|
||||
"id": "U123",
|
||||
"name": "Mason Zeng",
|
||||
"nickname": "mzeng",
|
||||
}
|
||||
})),
|
||||
is_error: Some(false),
|
||||
meta: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
slack_channel_name_from_tool_result(
|
||||
Some(SLACK_CONNECTOR_ID),
|
||||
Some("get_profile"),
|
||||
&result
|
||||
),
|
||||
Some(SlackChannelName {
|
||||
channel_id: "U123".to_string(),
|
||||
channel_name: "@mzeng".to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_text_content_when_structured_content_is_missing() {
|
||||
let result = CallToolResult {
|
||||
content: vec![json!({
|
||||
"type": "text",
|
||||
"text": "{\"result\":\"mzeng (mzeng)\\nOpenAI\\nCodexing\\nUsers (1 results)\\n### Result 1 of 1\\nName: Matthew\\nZeng\\nUser ID: U07B9LBRPST\\nTitle: Codexing\"}",
|
||||
})],
|
||||
structured_content: None,
|
||||
is_error: Some(false),
|
||||
meta: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
slack_channel_name_from_tool_result(
|
||||
Some(SLACK_CONNECTOR_ID),
|
||||
Some("slack_read_user_profile"),
|
||||
&result
|
||||
),
|
||||
Some(SlackChannelName {
|
||||
channel_id: "U07B9LBRPST".to_string(),
|
||||
channel_name: "@mzeng".to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_slack_channel_name_from_structured_text_result() {
|
||||
let result = CallToolResult {
|
||||
content: Vec::new(),
|
||||
structured_content: Some(json!({
|
||||
"result": "mzeng (mzeng)\nOpenAI\nCodexing\nUsers (1 results)\n### Result 1 of 1\nName: Matthew\nZeng\nUser ID: U07B9LBRPST\nTitle: Codexing",
|
||||
})),
|
||||
is_error: Some(false),
|
||||
meta: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
slack_channel_name_from_tool_result(
|
||||
Some(SLACK_CONNECTOR_ID),
|
||||
Some("slack_read_user_profile"),
|
||||
&result
|
||||
),
|
||||
Some(SlackChannelName {
|
||||
channel_id: "U07B9LBRPST".to_string(),
|
||||
channel_name: "@mzeng".to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_slack_profile_tools() {
|
||||
let result = CallToolResult {
|
||||
content: Vec::new(),
|
||||
structured_content: Some(json!({
|
||||
"result": {
|
||||
"id": "U123",
|
||||
"name": "Mason Zeng",
|
||||
}
|
||||
})),
|
||||
is_error: Some(false),
|
||||
meta: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
slack_channel_name_from_tool_result(
|
||||
Some("connector_linear"),
|
||||
Some("get_profile"),
|
||||
&result
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_slack_channel_name_from_search_users_result() {
|
||||
let result = CallToolResult {
|
||||
content: Vec::new(),
|
||||
structured_content: Some(json!({
|
||||
"results": "# Search Results for: mzeng@openai.com\n\n## Users (1 results)\n### Result 1 of 1\nName: Matthew Zeng\nUser ID: U07B9LBRPST\nTitle: Codexing agent\nEmail: mzeng@openai.com",
|
||||
})),
|
||||
is_error: Some(false),
|
||||
meta: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
slack_channel_name_from_tool_result(
|
||||
Some(SLACK_CONNECTOR_ID),
|
||||
Some("slack_search_users"),
|
||||
&result
|
||||
),
|
||||
Some(SlackChannelName {
|
||||
channel_id: "U07B9LBRPST".to_string(),
|
||||
channel_name: "Matthew Zeng".to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_slack_channel_name_from_read_user_profile_results_result() {
|
||||
let result = CallToolResult {
|
||||
content: Vec::new(),
|
||||
structured_content: Some(json!({
|
||||
"results": "# Search Results for: mzeng@openai.com\n\n## Users (1 results)\n### Result 1 of 1\nName: Matthew Zeng\nUser ID: U07B9LBRPST\nTitle: Codexing agent\nEmail: mzeng@openai.com",
|
||||
})),
|
||||
is_error: Some(false),
|
||||
meta: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
slack_channel_name_from_tool_result(
|
||||
Some(SLACK_CONNECTOR_ID),
|
||||
Some("slack_read_user_profile"),
|
||||
&result
|
||||
),
|
||||
Some(SlackChannelName {
|
||||
channel_id: "U07B9LBRPST".to_string(),
|
||||
channel_name: "Matthew Zeng".to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translates_slack_send_message_tool_params() {
|
||||
assert_eq!(
|
||||
translated_slack_send_message_tool_params(
|
||||
Some(SLACK_CONNECTOR_ID),
|
||||
Some("slack_send_message"),
|
||||
Some(&json!({
|
||||
"channel_id": "U123",
|
||||
"message": "hi",
|
||||
})),
|
||||
Some("@mzeng"),
|
||||
),
|
||||
Some(json!({
|
||||
"channel_id": "U123",
|
||||
"message": "hi",
|
||||
"to": "@mzeng",
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_non_slack_send_message_tool_params_unchanged() {
|
||||
assert_eq!(
|
||||
translated_slack_send_message_tool_params(
|
||||
Some(SLACK_CONNECTOR_ID),
|
||||
Some("slack_search_channels"),
|
||||
Some(&json!({
|
||||
"query": "eng",
|
||||
})),
|
||||
Some("#eng"),
|
||||
),
|
||||
Some(json!({
|
||||
"query": "eng",
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,8 @@ pub(crate) struct SessionState {
|
||||
/// Startup regular task pre-created during session initialization.
|
||||
pub(crate) startup_regular_task: Option<JoinHandle<CodexResult<RegularTask>>>,
|
||||
pub(crate) active_connector_selection: HashSet<String>,
|
||||
slack_channel_names: HashMap<String, String>,
|
||||
slack_channel_names_by_connector_id: HashMap<String, HashMap<String, String>>,
|
||||
pub(crate) pending_session_start_source: Option<codex_hooks::SessionStartSource>,
|
||||
granted_permissions: Option<PermissionProfile>,
|
||||
}
|
||||
@@ -51,6 +53,8 @@ impl SessionState {
|
||||
previous_turn_settings: None,
|
||||
startup_regular_task: None,
|
||||
active_connector_selection: HashSet::new(),
|
||||
slack_channel_names: HashMap::new(),
|
||||
slack_channel_names_by_connector_id: HashMap::new(),
|
||||
pending_session_start_source: None,
|
||||
granted_permissions: None,
|
||||
}
|
||||
@@ -194,6 +198,44 @@ impl SessionState {
|
||||
self.active_connector_selection.clear();
|
||||
}
|
||||
|
||||
pub(crate) fn record_slack_channel_name(
|
||||
&mut self,
|
||||
connector_id: Option<&str>,
|
||||
channel_id: String,
|
||||
channel_name: String,
|
||||
) {
|
||||
self.slack_channel_names
|
||||
.insert(channel_id.clone(), channel_name.clone());
|
||||
if let Some(connector_id) = connector_id.map(str::trim).filter(|id| !id.is_empty()) {
|
||||
self.slack_channel_names_by_connector_id
|
||||
.entry(connector_id.to_string())
|
||||
.or_default()
|
||||
.insert(channel_id, channel_name);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn slack_channel_name(
|
||||
&self,
|
||||
connector_id: Option<&str>,
|
||||
channel_id: &str,
|
||||
) -> Option<String> {
|
||||
let channel_id = channel_id.trim();
|
||||
if channel_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
connector_id
|
||||
.map(str::trim)
|
||||
.filter(|id| !id.is_empty())
|
||||
.and_then(|connector_id| {
|
||||
self.slack_channel_names_by_connector_id
|
||||
.get(connector_id)?
|
||||
.get(channel_id)
|
||||
.cloned()
|
||||
})
|
||||
.or_else(|| self.slack_channel_names.get(channel_id).cloned())
|
||||
}
|
||||
|
||||
pub(crate) fn set_pending_session_start_source(
|
||||
&mut self,
|
||||
value: Option<codex_hooks::SessionStartSource>,
|
||||
@@ -272,6 +314,41 @@ mod tests {
|
||||
assert_eq!(state.get_connector_selection(), HashSet::new());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slack_channel_lookup_prefers_connector_match_and_falls_back_to_global() {
|
||||
let session_configuration = make_session_configuration_for_tests().await;
|
||||
let mut state = SessionState::new(session_configuration);
|
||||
state.record_slack_channel_name(
|
||||
Some("connector_a"),
|
||||
"U123".to_string(),
|
||||
"@alice".to_string(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
state.slack_channel_name(Some("connector_a"), "U123"),
|
||||
Some("@alice".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
state.slack_channel_name(Some("connector_b"), "U123"),
|
||||
Some("@alice".to_string())
|
||||
);
|
||||
|
||||
state.record_slack_channel_name(
|
||||
Some("connector_b"),
|
||||
"U123".to_string(),
|
||||
"@ally".to_string(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
state.slack_channel_name(Some("connector_a"), "U123"),
|
||||
Some("@alice".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
state.slack_channel_name(Some("connector_b"), "U123"),
|
||||
Some("@ally".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_rate_limits_defaults_limit_id_to_codex_when_missing() {
|
||||
let session_configuration = make_session_configuration_for_tests().await;
|
||||
|
||||
Reference in New Issue
Block a user