Compare commits

...

2 Commits

Author SHA1 Message Date
Matthew Zeng
d0f8f87960 update 2026-03-12 19:10:55 -07:00
Matthew Zeng
e5995b1f70 update 2026-03-12 11:38:40 -07:00
8 changed files with 862 additions and 15 deletions

View File

@@ -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 = {

View File

@@ -591,6 +591,10 @@
"server_name": "codex_apps",
"tool_title": "slack_send_message",
"template_params": [
{
"name": "to",
"label": "To"
},
{
"name": "channel_id",
"label": "Conversation"

View File

@@ -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;

View File

@@ -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>,

View File

@@ -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 {

View File

@@ -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(

View 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",
}))
);
}
}

View File

@@ -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;