mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
update
This commit is contained in:
Generated
+1
-1
@@ -2116,8 +2116,8 @@ dependencies = [
|
||||
name = "codex-i18n"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"codex-utils-cargo-bin",
|
||||
"fluent-bundle 0.16.0",
|
||||
"include_dir",
|
||||
"pretty_assertions",
|
||||
"sys-locale",
|
||||
"thiserror 2.0.18",
|
||||
|
||||
@@ -195,10 +195,7 @@ pub(crate) async fn monitor_action(
|
||||
} else if !rationale.is_empty() {
|
||||
ArcMonitorOutcome::AskUser(rationale.to_string())
|
||||
} else {
|
||||
ArcMonitorOutcome::AskUser(
|
||||
"Additional confirmation is required before this tool call can continue."
|
||||
.to_string(),
|
||||
)
|
||||
ArcMonitorOutcome::AskUser(String::new())
|
||||
}
|
||||
}
|
||||
ArcMonitorResultOutcome::SteerModel => {
|
||||
@@ -207,9 +204,7 @@ pub(crate) async fn monitor_action(
|
||||
} else if !short_reason.is_empty() {
|
||||
ArcMonitorOutcome::SteerModel(short_reason.to_string())
|
||||
} else {
|
||||
ArcMonitorOutcome::SteerModel(
|
||||
"Tool call was cancelled because of safety risks.".to_string(),
|
||||
)
|
||||
ArcMonitorOutcome::SteerModel(String::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,6 +329,58 @@ async fn monitor_action_posts_expected_arc_request() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial(arc_monitor_env)]
|
||||
async fn monitor_action_returns_empty_reason_when_arc_requires_generic_confirmation() {
|
||||
let server = MockServer::start().await;
|
||||
let (session, mut turn_context) = make_session_and_context().await;
|
||||
turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth(
|
||||
crate::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
||||
));
|
||||
|
||||
let mut config = (*turn_context.config).clone();
|
||||
config.chatgpt_base_url = server.uri();
|
||||
turn_context.config = Arc::new(config);
|
||||
|
||||
session
|
||||
.record_into_history(
|
||||
&[ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "please run the tool".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}],
|
||||
&turn_context,
|
||||
)
|
||||
.await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/codex/safety/arc"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"outcome": "ask-user",
|
||||
"short_reason": "",
|
||||
"rationale": "",
|
||||
"risk_score": 42,
|
||||
"risk_level": "medium",
|
||||
"evidence": [],
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let outcome = monitor_action(
|
||||
&session,
|
||||
&turn_context,
|
||||
serde_json::json!({ "tool": "mcp_tool_call" }),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(outcome, ArcMonitorOutcome::AskUser(String::new()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial(arc_monitor_env)]
|
||||
async fn monitor_action_uses_env_url_and_token_overrides() {
|
||||
|
||||
@@ -135,7 +135,8 @@ fn render_question_template(
|
||||
let connector_name = connector_name
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())?;
|
||||
let rendered = format_message(locale, question_id, &[("connector_name", connector_name)])?;
|
||||
let args = [("connector_name", connector_name)];
|
||||
let rendered = format_message(locale, question_id, &args);
|
||||
let rendered = rendered.trim();
|
||||
if rendered.is_empty() {
|
||||
return None;
|
||||
@@ -153,7 +154,7 @@ fn render_tool_params(
|
||||
let mut handled_names = HashSet::new();
|
||||
|
||||
for template_param in template_params {
|
||||
let label = format_message(locale, &template_param.label_id, &[])?;
|
||||
let label = format_message(locale, &template_param.label_id, &[]);
|
||||
let label = label.trim();
|
||||
if label.is_empty() {
|
||||
return None;
|
||||
@@ -235,34 +236,31 @@ mod tests {
|
||||
})),
|
||||
);
|
||||
|
||||
let rendered = rendered.expect("template should render");
|
||||
assert_eq!(rendered.question, rendered.elicitation_message);
|
||||
assert_eq!(rendered.question.contains("Calendar"), true);
|
||||
assert_eq!(
|
||||
rendered,
|
||||
Some(RenderedMcpToolApprovalTemplate {
|
||||
question: "Allow Calendar to create an event?".to_string(),
|
||||
elicitation_message: "Allow Calendar to create an event?".to_string(),
|
||||
tool_params: Some(json!({
|
||||
"title": "Roadmap review",
|
||||
"calendar_id": "primary",
|
||||
"timezone": "UTC",
|
||||
})),
|
||||
tool_params_display: vec![
|
||||
RenderedMcpToolApprovalParam {
|
||||
name: "calendar_id".to_string(),
|
||||
value: json!("primary"),
|
||||
display_name: "Calendar".to_string(),
|
||||
},
|
||||
RenderedMcpToolApprovalParam {
|
||||
name: "title".to_string(),
|
||||
value: json!("Roadmap review"),
|
||||
display_name: "Title".to_string(),
|
||||
},
|
||||
RenderedMcpToolApprovalParam {
|
||||
name: "timezone".to_string(),
|
||||
value: json!("UTC"),
|
||||
display_name: "timezone".to_string(),
|
||||
},
|
||||
],
|
||||
})
|
||||
rendered.tool_params,
|
||||
Some(json!({
|
||||
"title": "Roadmap review",
|
||||
"calendar_id": "primary",
|
||||
"timezone": "UTC",
|
||||
}))
|
||||
);
|
||||
assert_eq!(
|
||||
rendered
|
||||
.tool_params_display
|
||||
.iter()
|
||||
.map(|param| param.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["calendar_id", "title", "timezone"]
|
||||
);
|
||||
assert_eq!(
|
||||
rendered
|
||||
.tool_params_display
|
||||
.last()
|
||||
.map(|param| param.display_name.as_str()),
|
||||
Some("timezone")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -379,46 +377,4 @@ mod tests {
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_exact_match_in_chinese() {
|
||||
let templates = vec![ConsequentialToolMessageTemplate {
|
||||
connector_id: "google_docs".to_string(),
|
||||
server_name: "codex_apps".to_string(),
|
||||
tool_title: "create_document".to_string(),
|
||||
question_id: "approval-question-google-docs-create-document".to_string(),
|
||||
template_params: vec![ConsequentialToolTemplateParam {
|
||||
name: "title".to_string(),
|
||||
label_id: "approval-param-title".to_string(),
|
||||
}],
|
||||
}];
|
||||
|
||||
let rendered = render_mcp_tool_approval_template_from_templates(
|
||||
&templates,
|
||||
"zh-CN",
|
||||
"codex_apps",
|
||||
Some("google_docs"),
|
||||
Some("Google Docs"),
|
||||
Some("create_document"),
|
||||
Some(&json!({
|
||||
"title": "路线图评审",
|
||||
})),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
rendered,
|
||||
Some(RenderedMcpToolApprovalTemplate {
|
||||
question: "允许 Google Docs 创建文档吗?".to_string(),
|
||||
elicitation_message: "允许 Google Docs 创建文档吗?".to_string(),
|
||||
tool_params: Some(json!({
|
||||
"title": "路线图评审",
|
||||
})),
|
||||
tool_params_display: vec![RenderedMcpToolApprovalParam {
|
||||
name: "title".to_string(),
|
||||
value: json!("路线图评审"),
|
||||
display_name: "标题".to_string(),
|
||||
}],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use codex_app_server_protocol::McpElicitationObjectType;
|
||||
use codex_app_server_protocol::McpElicitationSchema;
|
||||
use codex_app_server_protocol::McpServerElicitationRequest;
|
||||
use codex_app_server_protocol::McpServerElicitationRequestParams;
|
||||
use codex_i18n::format_message;
|
||||
use tracing::error;
|
||||
|
||||
use crate::analytics_client::AppInvocation;
|
||||
@@ -319,6 +320,117 @@ fn sanitize_mcp_tool_result_for_model(
|
||||
})
|
||||
}
|
||||
|
||||
fn arc_monitor_additional_confirmation_message(locale: &str) -> String {
|
||||
localized_message(locale, "arc-monitor-additional-confirmation", &[])
|
||||
}
|
||||
|
||||
fn arc_monitor_interrupt_message(locale: &str, reason: &str) -> String {
|
||||
let reason = reason.trim();
|
||||
if reason.is_empty() {
|
||||
return localized_message(locale, "approval-safety-cancelled", &[]);
|
||||
}
|
||||
|
||||
localized_message(
|
||||
locale,
|
||||
"approval-safety-cancelled-with-reason",
|
||||
&[("reason", reason)],
|
||||
)
|
||||
}
|
||||
|
||||
fn mcp_tool_approval_copy(locale: &str) -> McpToolApprovalCopy {
|
||||
McpToolApprovalCopy {
|
||||
header: localized_message(locale, "approval-header", &[]),
|
||||
accept: McpToolApprovalOptionCopy {
|
||||
label: localized_message(locale, "approval-option-allow", &[]),
|
||||
description: localized_message(locale, "approval-option-allow-description", &[]),
|
||||
},
|
||||
accept_for_session: McpToolApprovalOptionCopy {
|
||||
label: localized_message(locale, "approval-option-allow-session", &[]),
|
||||
description: localized_message(
|
||||
locale,
|
||||
"approval-option-allow-session-description",
|
||||
&[],
|
||||
),
|
||||
},
|
||||
accept_and_remember: McpToolApprovalOptionCopy {
|
||||
label: localized_message(locale, "approval-option-allow-remember", &[]),
|
||||
description: localized_message(
|
||||
locale,
|
||||
"approval-option-allow-remember-description",
|
||||
&[],
|
||||
),
|
||||
},
|
||||
cancel: McpToolApprovalOptionCopy {
|
||||
label: localized_message(locale, "approval-option-cancel", &[]),
|
||||
description: localized_message(locale, "approval-option-cancel-description", &[]),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct McpToolApprovalOptionCopy {
|
||||
label: String,
|
||||
description: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct McpToolApprovalCopy {
|
||||
header: String,
|
||||
accept: McpToolApprovalOptionCopy,
|
||||
accept_for_session: McpToolApprovalOptionCopy,
|
||||
accept_and_remember: McpToolApprovalOptionCopy,
|
||||
cancel: McpToolApprovalOptionCopy,
|
||||
}
|
||||
|
||||
fn mcp_tool_approval_fallback_message(
|
||||
locale: &str,
|
||||
server: &str,
|
||||
tool_name: &str,
|
||||
connector_name: Option<&str>,
|
||||
) -> String {
|
||||
if let Some(connector_name) = connector_name
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
{
|
||||
return format_message(
|
||||
locale,
|
||||
"approval-fallback-question-connector",
|
||||
&[("connector_name", connector_name), ("tool_name", tool_name)],
|
||||
);
|
||||
}
|
||||
|
||||
if server == CODEX_APPS_MCP_SERVER_NAME {
|
||||
return format_message(
|
||||
locale,
|
||||
"approval-fallback-question-this-app",
|
||||
&[("tool_name", tool_name)],
|
||||
);
|
||||
}
|
||||
|
||||
format_message(
|
||||
locale,
|
||||
"approval-fallback-question-server",
|
||||
&[("server", server), ("tool_name", tool_name)],
|
||||
)
|
||||
}
|
||||
|
||||
fn mcp_tool_approval_question_text(
|
||||
locale: &str,
|
||||
question: String,
|
||||
monitor_reason: Option<&str>,
|
||||
) -> String {
|
||||
match monitor_reason.map(str::trim) {
|
||||
Some(reason) if !reason.is_empty() => {
|
||||
localized_message(locale, "approval-monitor-question", &[("reason", reason)])
|
||||
}
|
||||
_ => question,
|
||||
}
|
||||
}
|
||||
|
||||
fn localized_message(locale: &str, message_id: &str, args: &[(&str, &str)]) -> String {
|
||||
format_message(locale, message_id, args)
|
||||
}
|
||||
|
||||
async fn notify_mcp_tool_call_event(sess: &Session, turn_context: &TurnContext, event: EventMsg) {
|
||||
sess.send_event(turn_context, event).await;
|
||||
}
|
||||
@@ -421,15 +533,11 @@ struct McpToolApprovalElicitationRequest<'a> {
|
||||
}
|
||||
|
||||
pub(crate) const MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX: &str = "mcp_tool_call_approval";
|
||||
pub(crate) const MCP_TOOL_APPROVAL_ACCEPT: &str = "Allow";
|
||||
pub(crate) const MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION: &str = "Allow for this session";
|
||||
// Internal-only token used when guardian auto-reviews delegated MCP approvals on the
|
||||
// RequestUserInput compatibility path. That legacy MCP prompt has allow/cancel labels but no
|
||||
// real "Decline" answer, so this lets guardian denials round-trip distinctly from user cancel.
|
||||
// This is not a user-facing option.
|
||||
pub(crate) const MCP_TOOL_APPROVAL_DECLINE_SYNTHETIC: &str = "__codex_mcp_decline__";
|
||||
const MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER: &str = "Allow and don't ask me again";
|
||||
const MCP_TOOL_APPROVAL_CANCEL: &str = "Cancel";
|
||||
const MCP_TOOL_APPROVAL_KIND_KEY: &str = "codex_approval_kind";
|
||||
const MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL: &str = "mcp_tool_call";
|
||||
const MCP_TOOL_APPROVAL_PERSIST_KEY: &str = "persist";
|
||||
@@ -492,11 +600,15 @@ async fn maybe_request_mcp_tool_approval(
|
||||
{
|
||||
ArcMonitorOutcome::Ok => return None,
|
||||
ArcMonitorOutcome::AskUser(reason) => {
|
||||
monitor_reason = Some(reason);
|
||||
monitor_reason = Some(if reason.trim().is_empty() {
|
||||
arc_monitor_additional_confirmation_message(&turn_context.config.locale)
|
||||
} else {
|
||||
reason
|
||||
});
|
||||
}
|
||||
ArcMonitorOutcome::SteerModel(reason) => {
|
||||
return Some(McpToolApprovalDecision::BlockedBySafetyMonitor(
|
||||
arc_monitor_interrupt_message(&reason),
|
||||
arc_monitor_interrupt_message(&turn_context.config.locale, &reason),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -563,6 +675,7 @@ async fn maybe_request_mcp_tool_approval(
|
||||
.map(|rendered_template| rendered_template.tool_params_display.clone())
|
||||
.or_else(|| build_mcp_tool_approval_display_params(invocation.arguments.as_ref()));
|
||||
let mut question = build_mcp_tool_approval_question(
|
||||
&turn_context.config.locale,
|
||||
question_id.clone(),
|
||||
&invocation.server,
|
||||
&invocation.tool,
|
||||
@@ -572,8 +685,11 @@ async fn maybe_request_mcp_tool_approval(
|
||||
.as_ref()
|
||||
.map(|rendered_template| rendered_template.question.as_str()),
|
||||
);
|
||||
question.question =
|
||||
mcp_tool_approval_question_text(question.question, monitor_reason.as_deref());
|
||||
question.question = mcp_tool_approval_question_text(
|
||||
&turn_context.config.locale,
|
||||
question.question,
|
||||
monitor_reason.as_deref(),
|
||||
);
|
||||
if tool_call_mcp_elicitation_enabled {
|
||||
let request_id = rmcp::model::RequestId::String(
|
||||
format!("{MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX}_{call_id}").into(),
|
||||
@@ -599,6 +715,7 @@ async fn maybe_request_mcp_tool_approval(
|
||||
},
|
||||
);
|
||||
let decision = parse_mcp_tool_approval_elicitation_response(
|
||||
&turn_context.config.locale,
|
||||
sess.request_mcp_server_elicitation(turn_context.as_ref(), request_id, params)
|
||||
.await,
|
||||
&question_id,
|
||||
@@ -622,7 +739,7 @@ async fn maybe_request_mcp_tool_approval(
|
||||
.request_user_input(turn_context.as_ref(), call_id.to_string(), args)
|
||||
.await;
|
||||
let decision = normalize_approval_decision_for_mode(
|
||||
parse_mcp_tool_approval_response(response, &question_id),
|
||||
parse_mcp_tool_approval_response(&turn_context.config.locale, response, &question_id),
|
||||
approval_mode,
|
||||
);
|
||||
apply_mcp_tool_approval_decision(
|
||||
@@ -821,6 +938,7 @@ async fn lookup_mcp_app_usage_metadata(
|
||||
}
|
||||
|
||||
fn build_mcp_tool_approval_question(
|
||||
locale: &str,
|
||||
question_id: String,
|
||||
server: &str,
|
||||
tool_name: &str,
|
||||
@@ -828,37 +946,38 @@ fn build_mcp_tool_approval_question(
|
||||
prompt_options: McpToolApprovalPromptOptions,
|
||||
question_override: Option<&str>,
|
||||
) -> RequestUserInputQuestion {
|
||||
let approval_copy = mcp_tool_approval_copy(locale);
|
||||
let question = question_override
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| {
|
||||
build_mcp_tool_approval_fallback_message(server, tool_name, connector_name)
|
||||
mcp_tool_approval_fallback_message(locale, server, tool_name, connector_name)
|
||||
});
|
||||
let question = format!("{}?", question.trim_end_matches('?'));
|
||||
|
||||
let mut options = vec![RequestUserInputQuestionOption {
|
||||
label: MCP_TOOL_APPROVAL_ACCEPT.to_string(),
|
||||
description: "Run the tool and continue.".to_string(),
|
||||
label: approval_copy.accept.label.clone(),
|
||||
description: approval_copy.accept.description,
|
||||
}];
|
||||
if prompt_options.allow_session_remember {
|
||||
options.push(RequestUserInputQuestionOption {
|
||||
label: MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION.to_string(),
|
||||
description: "Run the tool and remember this choice for this session.".to_string(),
|
||||
label: approval_copy.accept_for_session.label.clone(),
|
||||
description: approval_copy.accept_for_session.description,
|
||||
});
|
||||
}
|
||||
if prompt_options.allow_persistent_approval {
|
||||
options.push(RequestUserInputQuestionOption {
|
||||
label: MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER.to_string(),
|
||||
description: "Run the tool and remember this choice for future tool calls.".to_string(),
|
||||
label: approval_copy.accept_and_remember.label.clone(),
|
||||
description: approval_copy.accept_and_remember.description,
|
||||
});
|
||||
}
|
||||
options.push(RequestUserInputQuestionOption {
|
||||
label: MCP_TOOL_APPROVAL_CANCEL.to_string(),
|
||||
description: "Cancel this tool call.".to_string(),
|
||||
label: approval_copy.cancel.label,
|
||||
description: approval_copy.cancel.description,
|
||||
});
|
||||
|
||||
RequestUserInputQuestion {
|
||||
id: question_id,
|
||||
header: "Approve app tool call?".to_string(),
|
||||
header: approval_copy.header,
|
||||
question,
|
||||
is_other: false,
|
||||
is_secret: false,
|
||||
@@ -866,43 +985,6 @@ fn build_mcp_tool_approval_question(
|
||||
}
|
||||
}
|
||||
|
||||
fn build_mcp_tool_approval_fallback_message(
|
||||
server: &str,
|
||||
tool_name: &str,
|
||||
connector_name: Option<&str>,
|
||||
) -> String {
|
||||
let actor = connector_name
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| {
|
||||
if server == CODEX_APPS_MCP_SERVER_NAME {
|
||||
"this app".to_string()
|
||||
} else {
|
||||
format!("the {server} MCP server")
|
||||
}
|
||||
});
|
||||
format!("Allow {actor} to run tool \"{tool_name}\"?")
|
||||
}
|
||||
|
||||
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() => {
|
||||
format!("Tool call needs your approval. Reason: {reason}")
|
||||
}
|
||||
_ => question,
|
||||
}
|
||||
}
|
||||
|
||||
fn arc_monitor_interrupt_message(reason: &str) -> String {
|
||||
let reason = reason.trim();
|
||||
if reason.is_empty() {
|
||||
"Tool call was cancelled because of safety risks.".to_string()
|
||||
} else {
|
||||
format!("Tool call was cancelled because of safety risks: {reason}")
|
||||
}
|
||||
}
|
||||
|
||||
fn build_mcp_tool_approval_elicitation_request(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
@@ -1053,6 +1135,7 @@ fn build_mcp_tool_approval_display_params(
|
||||
}
|
||||
|
||||
fn parse_mcp_tool_approval_elicitation_response(
|
||||
locale: &str,
|
||||
response: Option<ElicitationResponse>,
|
||||
question_id: &str,
|
||||
) -> McpToolApprovalDecision {
|
||||
@@ -1078,6 +1161,7 @@ fn parse_mcp_tool_approval_elicitation_response(
|
||||
}
|
||||
|
||||
match parse_mcp_tool_approval_response(
|
||||
locale,
|
||||
request_user_input_response_from_elicitation_content(response.content),
|
||||
question_id,
|
||||
) {
|
||||
@@ -1118,9 +1202,11 @@ fn request_user_input_response_from_elicitation_content(
|
||||
}
|
||||
|
||||
fn parse_mcp_tool_approval_response(
|
||||
locale: &str,
|
||||
response: Option<RequestUserInputResponse>,
|
||||
question_id: &str,
|
||||
) -> McpToolApprovalDecision {
|
||||
let approval_copy = mcp_tool_approval_copy(locale);
|
||||
let Some(response) = response else {
|
||||
return McpToolApprovalDecision::Cancel;
|
||||
};
|
||||
@@ -1138,17 +1224,17 @@ fn parse_mcp_tool_approval_response(
|
||||
McpToolApprovalDecision::Decline
|
||||
} else if answers
|
||||
.iter()
|
||||
.any(|answer| answer == MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION)
|
||||
.any(|answer| answer == &approval_copy.accept_for_session.label)
|
||||
{
|
||||
McpToolApprovalDecision::AcceptForSession
|
||||
} else if answers
|
||||
.iter()
|
||||
.any(|answer| answer == MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER)
|
||||
.any(|answer| answer == &approval_copy.accept_and_remember.label)
|
||||
{
|
||||
McpToolApprovalDecision::AcceptAndRemember
|
||||
} else if answers
|
||||
.iter()
|
||||
.any(|answer| answer == MCP_TOOL_APPROVAL_ACCEPT)
|
||||
.any(|answer| answer == &approval_copy.accept.label)
|
||||
{
|
||||
McpToolApprovalDecision::Accept
|
||||
} else {
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::config::types::AppToolConfig;
|
||||
use crate::config::types::AppToolsConfig;
|
||||
use crate::config::types::AppsConfigToml;
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
use codex_i18n::DEFAULT_LOCALE;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
@@ -61,6 +62,10 @@ fn prompt_options(
|
||||
}
|
||||
}
|
||||
|
||||
fn approval_copy() -> McpToolApprovalCopy {
|
||||
mcp_tool_approval_copy(DEFAULT_LOCALE)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_required_when_read_only_false_and_destructive() {
|
||||
let annotations = annotations(Some(false), Some(true), None);
|
||||
@@ -99,19 +104,24 @@ fn prompt_mode_does_not_allow_persistent_remember() {
|
||||
|
||||
#[test]
|
||||
fn approval_question_text_prepends_safety_reason() {
|
||||
assert_eq!(
|
||||
mcp_tool_approval_question_text(
|
||||
"Allow this action?".to_string(),
|
||||
Some("This tool may contact an external system."),
|
||||
),
|
||||
"Tool call needs your approval. Reason: This tool may contact an external system."
|
||||
let rendered = mcp_tool_approval_question_text(
|
||||
DEFAULT_LOCALE,
|
||||
"Allow this action?".to_string(),
|
||||
Some("This tool may contact an external system."),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
rendered.contains("This tool may contact an external system."),
|
||||
true
|
||||
);
|
||||
assert_ne!(rendered, "Allow this action?".to_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn approval_elicitation_request_uses_message_override_and_preserves_tool_params_keys() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
let question = build_mcp_tool_approval_question(
|
||||
DEFAULT_LOCALE,
|
||||
"q".to_string(),
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
"create_event",
|
||||
@@ -205,6 +215,7 @@ async fn approval_elicitation_request_uses_message_override_and_preserves_tool_p
|
||||
#[test]
|
||||
fn custom_mcp_tool_question_mentions_server_name() {
|
||||
let question = build_mcp_tool_approval_question(
|
||||
DEFAULT_LOCALE,
|
||||
"q".to_string(),
|
||||
"custom_server",
|
||||
"run_action",
|
||||
@@ -213,7 +224,7 @@ fn custom_mcp_tool_question_mentions_server_name() {
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(question.header, "Approve app tool call?");
|
||||
assert_eq!(question.header, approval_copy().header);
|
||||
assert_eq!(
|
||||
question.question,
|
||||
"Allow the custom_server MCP server to run tool \"run_action\"?"
|
||||
@@ -224,13 +235,14 @@ fn custom_mcp_tool_question_mentions_server_name() {
|
||||
.expect("options")
|
||||
.into_iter()
|
||||
.map(|option| option.label)
|
||||
.any(|label| label == MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER)
|
||||
.any(|label| label == approval_copy().accept_and_remember.label)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_apps_tool_question_uses_fallback_app_label() {
|
||||
let question = build_mcp_tool_approval_question(
|
||||
DEFAULT_LOCALE,
|
||||
"q".to_string(),
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
"run_action",
|
||||
@@ -248,6 +260,7 @@ fn codex_apps_tool_question_uses_fallback_app_label() {
|
||||
#[test]
|
||||
fn trusted_codex_apps_tool_question_offers_always_allow() {
|
||||
let question = build_mcp_tool_approval_question(
|
||||
DEFAULT_LOCALE,
|
||||
"q".to_string(),
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
"run_action",
|
||||
@@ -256,14 +269,15 @@ fn trusted_codex_apps_tool_question_offers_always_allow() {
|
||||
None,
|
||||
);
|
||||
let options = question.options.expect("options");
|
||||
let approval_copy = approval_copy();
|
||||
|
||||
assert!(options.iter().any(|option| {
|
||||
option.label == MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION
|
||||
&& option.description == "Run the tool and remember this choice for this session."
|
||||
option.label == approval_copy.accept_for_session.label
|
||||
&& option.description == approval_copy.accept_for_session.description
|
||||
}));
|
||||
assert!(options.iter().any(|option| {
|
||||
option.label == MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER
|
||||
&& option.description == "Run the tool and remember this choice for future tool calls."
|
||||
option.label == approval_copy.accept_and_remember.label
|
||||
&& option.description == approval_copy.accept_and_remember.description
|
||||
}));
|
||||
assert_eq!(
|
||||
options
|
||||
@@ -271,10 +285,10 @@ fn trusted_codex_apps_tool_question_offers_always_allow() {
|
||||
.map(|option| option.label)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
MCP_TOOL_APPROVAL_ACCEPT.to_string(),
|
||||
MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION.to_string(),
|
||||
MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER.to_string(),
|
||||
MCP_TOOL_APPROVAL_CANCEL.to_string(),
|
||||
approval_copy.accept.label,
|
||||
approval_copy.accept_for_session.label,
|
||||
approval_copy.accept_and_remember.label,
|
||||
approval_copy.cancel.label,
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -288,6 +302,7 @@ fn codex_apps_tool_question_without_elicitation_omits_always_allow() {
|
||||
};
|
||||
let persistent_key = session_key.clone();
|
||||
let question = build_mcp_tool_approval_question(
|
||||
DEFAULT_LOCALE,
|
||||
"q".to_string(),
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
"run_action",
|
||||
@@ -304,9 +319,9 @@ fn codex_apps_tool_question_without_elicitation_omits_always_allow() {
|
||||
.map(|option| option.label)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
MCP_TOOL_APPROVAL_ACCEPT.to_string(),
|
||||
MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION.to_string(),
|
||||
MCP_TOOL_APPROVAL_CANCEL.to_string(),
|
||||
approval_copy().accept.label,
|
||||
approval_copy().accept_for_session.label,
|
||||
approval_copy().cancel.label,
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -314,6 +329,7 @@ fn codex_apps_tool_question_without_elicitation_omits_always_allow() {
|
||||
#[test]
|
||||
fn custom_mcp_tool_question_offers_session_remember_without_always_allow() {
|
||||
let question = build_mcp_tool_approval_question(
|
||||
DEFAULT_LOCALE,
|
||||
"q".to_string(),
|
||||
"custom_server",
|
||||
"run_action",
|
||||
@@ -330,9 +346,9 @@ fn custom_mcp_tool_question_offers_session_remember_without_always_allow() {
|
||||
.map(|option| option.label)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
MCP_TOOL_APPROVAL_ACCEPT.to_string(),
|
||||
MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION.to_string(),
|
||||
MCP_TOOL_APPROVAL_CANCEL.to_string(),
|
||||
approval_copy().accept.label,
|
||||
approval_copy().accept_for_session.label,
|
||||
approval_copy().cancel.label,
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -474,9 +490,10 @@ fn codex_apps_tool_call_request_meta_includes_codex_apps_meta() {
|
||||
|
||||
#[test]
|
||||
fn accepted_elicitation_content_converts_to_request_user_input_response() {
|
||||
let approval_copy = approval_copy();
|
||||
let response = request_user_input_response_from_elicitation_content(Some(serde_json::json!(
|
||||
{
|
||||
"approval": MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER,
|
||||
"approval": approval_copy.accept_and_remember.label,
|
||||
}
|
||||
)));
|
||||
|
||||
@@ -486,7 +503,7 @@ fn accepted_elicitation_content_converts_to_request_user_input_response() {
|
||||
answers: std::collections::HashMap::from([(
|
||||
"approval".to_string(),
|
||||
RequestUserInputAnswer {
|
||||
answers: vec![MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER.to_string()],
|
||||
answers: vec![approval_copy.accept_and_remember.label],
|
||||
},
|
||||
)]),
|
||||
})
|
||||
@@ -743,10 +760,11 @@ fn approval_elicitation_meta_merges_session_and_always_persist_with_connector_so
|
||||
#[test]
|
||||
fn declined_elicitation_response_stays_decline() {
|
||||
let response = parse_mcp_tool_approval_elicitation_response(
|
||||
DEFAULT_LOCALE,
|
||||
Some(ElicitationResponse {
|
||||
action: ElicitationAction::Decline,
|
||||
content: Some(serde_json::json!({
|
||||
"approval": MCP_TOOL_APPROVAL_ACCEPT,
|
||||
"approval": approval_copy().accept.label,
|
||||
})),
|
||||
meta: None,
|
||||
}),
|
||||
@@ -759,6 +777,7 @@ fn declined_elicitation_response_stays_decline() {
|
||||
#[test]
|
||||
fn synthetic_decline_request_user_input_response_stays_decline() {
|
||||
let response = parse_mcp_tool_approval_response(
|
||||
DEFAULT_LOCALE,
|
||||
Some(RequestUserInputResponse {
|
||||
answers: HashMap::from([(
|
||||
"approval".to_string(),
|
||||
@@ -776,6 +795,7 @@ fn synthetic_decline_request_user_input_response_stays_decline() {
|
||||
#[test]
|
||||
fn accepted_elicitation_response_uses_always_persist_meta() {
|
||||
let response = parse_mcp_tool_approval_elicitation_response(
|
||||
DEFAULT_LOCALE,
|
||||
Some(ElicitationResponse {
|
||||
action: ElicitationAction::Accept,
|
||||
content: None,
|
||||
@@ -792,6 +812,7 @@ fn accepted_elicitation_response_uses_always_persist_meta() {
|
||||
#[test]
|
||||
fn accepted_elicitation_response_uses_session_persist_meta() {
|
||||
let response = parse_mcp_tool_approval_elicitation_response(
|
||||
DEFAULT_LOCALE,
|
||||
Some(ElicitationResponse {
|
||||
action: ElicitationAction::Accept,
|
||||
content: None,
|
||||
@@ -808,6 +829,7 @@ fn accepted_elicitation_response_uses_session_persist_meta() {
|
||||
#[test]
|
||||
fn accepted_elicitation_without_content_defaults_to_accept() {
|
||||
let response = parse_mcp_tool_approval_elicitation_response(
|
||||
DEFAULT_LOCALE,
|
||||
Some(ElicitationResponse {
|
||||
action: ElicitationAction::Accept,
|
||||
content: None,
|
||||
@@ -991,12 +1013,13 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_for_model() {
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
decision,
|
||||
Some(McpToolApprovalDecision::BlockedBySafetyMonitor(
|
||||
"Tool call was cancelled because of safety risks: high-risk action".to_string(),
|
||||
))
|
||||
);
|
||||
match decision {
|
||||
Some(McpToolApprovalDecision::BlockedBySafetyMonitor(message)) => {
|
||||
assert_eq!(message.contains("high-risk action"), true);
|
||||
assert_eq!(message.is_empty(), false);
|
||||
}
|
||||
other => panic!("expected BlockedBySafetyMonitor, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -3,5 +3,6 @@ load("//:defs.bzl", "codex_rust_crate")
|
||||
codex_rust_crate(
|
||||
name = "i18n",
|
||||
crate_name = "codex_i18n",
|
||||
compile_data = glob(["src/locales/**"]),
|
||||
lib_data_extra = glob(["src/locales/**"]),
|
||||
test_data_extra = glob(["src/locales/**"]),
|
||||
)
|
||||
|
||||
@@ -12,8 +12,8 @@ path = "src/lib.rs"
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
codex-utils-cargo-bin = { workspace = true }
|
||||
fluent-bundle = { workspace = true }
|
||||
include_dir = { workspace = true }
|
||||
sys-locale = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
unic-langid = { workspace = true }
|
||||
|
||||
+133
-65
@@ -1,15 +1,18 @@
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsStr;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use fluent_bundle::FluentArgs;
|
||||
use fluent_bundle::FluentResource;
|
||||
use fluent_bundle::concurrent::FluentBundle;
|
||||
use include_dir::Dir;
|
||||
use include_dir::include_dir;
|
||||
use thiserror::Error;
|
||||
use unic_langid::LanguageIdentifier;
|
||||
|
||||
const LOCALES_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/locales");
|
||||
const LOCALES_DIR: &str = "src/locales";
|
||||
pub const DEFAULT_LOCALE: &str = "en-US";
|
||||
|
||||
type Bundle = FluentBundle<FluentResource>;
|
||||
@@ -20,9 +23,13 @@ pub enum LocaleError {
|
||||
InvalidLocale(String),
|
||||
}
|
||||
|
||||
static SUPPORTED_LOCALES: LazyLock<Vec<String>> = LazyLock::new(load_supported_locales);
|
||||
struct LocaleResources {
|
||||
supported_locales: Vec<String>,
|
||||
bundles: HashMap<String, Bundle>,
|
||||
}
|
||||
|
||||
static BUNDLES: LazyLock<HashMap<String, Bundle>> = LazyLock::new(load_bundles);
|
||||
static LOCALE_RESOURCES: LazyLock<Option<LocaleResources>> =
|
||||
LazyLock::new(|| load_locale_resources().ok());
|
||||
|
||||
pub fn resolve_locale(config_locale: Option<&str>) -> Result<String, LocaleError> {
|
||||
resolve_locale_with_system(config_locale, sys_locale::get_locale().as_deref())
|
||||
@@ -42,15 +49,21 @@ pub fn resolve_locale_with_system(
|
||||
Ok(negotiate_locale(requested.as_ref()))
|
||||
}
|
||||
|
||||
pub fn format_message(locale: &str, message_id: &str, args: &[(&str, &str)]) -> Option<String> {
|
||||
let bundle = BUNDLES
|
||||
.get(locale)
|
||||
.or_else(|| BUNDLES.get(DEFAULT_LOCALE))?;
|
||||
render_message(bundle, message_id, args).or_else(|| {
|
||||
BUNDLES
|
||||
.get(DEFAULT_LOCALE)
|
||||
.and_then(|fallback| render_message(fallback, message_id, args))
|
||||
})
|
||||
pub fn format_message(locale: &str, message_id: &str, args: &[(&str, &str)]) -> String {
|
||||
let rendered = LOCALE_RESOURCES.as_ref().and_then(|locale_resources| {
|
||||
locale_resources
|
||||
.bundles
|
||||
.get(locale)
|
||||
.and_then(|bundle| render_message(bundle, message_id, args))
|
||||
.or_else(|| {
|
||||
(locale != DEFAULT_LOCALE)
|
||||
.then_some(())
|
||||
.and_then(|()| locale_resources.bundles.get(DEFAULT_LOCALE))
|
||||
.and_then(|bundle| render_message(bundle, message_id, args))
|
||||
})
|
||||
});
|
||||
|
||||
rendered.unwrap_or_else(|| message_id.to_string())
|
||||
}
|
||||
|
||||
fn parse_locale(value: &str) -> Option<LanguageIdentifier> {
|
||||
@@ -75,62 +88,93 @@ fn negotiate_locale(requested: Option<&LanguageIdentifier>) -> String {
|
||||
};
|
||||
|
||||
let exact = requested.to_string();
|
||||
if SUPPORTED_LOCALES.iter().any(|locale| locale == &exact) {
|
||||
if supported_locales().iter().any(|locale| locale == &exact) {
|
||||
return exact;
|
||||
}
|
||||
|
||||
let requested_language = requested.language.as_str();
|
||||
SUPPORTED_LOCALES
|
||||
supported_locales()
|
||||
.iter()
|
||||
.find(|locale| locale.split('-').next() == Some(requested_language))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| DEFAULT_LOCALE.to_string())
|
||||
}
|
||||
|
||||
fn load_supported_locales() -> Vec<String> {
|
||||
let mut locales = LOCALES_DIR
|
||||
.dirs()
|
||||
.filter_map(|dir| dir.path().file_name())
|
||||
.filter_map(|name| name.to_str())
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<String>>();
|
||||
locales.sort();
|
||||
locales
|
||||
fn supported_locales() -> &'static [String] {
|
||||
LOCALE_RESOURCES
|
||||
.as_ref()
|
||||
.map(|locale_resources| locale_resources.supported_locales.as_slice())
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
|
||||
fn load_bundles() -> HashMap<String, Bundle> {
|
||||
fn load_locale_resources() -> io::Result<LocaleResources> {
|
||||
let locales_dir = resolve_locales_dir()?;
|
||||
let mut supported_locales = Vec::new();
|
||||
let mut bundles = HashMap::new();
|
||||
|
||||
for dir in LOCALES_DIR.dirs() {
|
||||
let Some(locale_name) = dir.path().file_name().and_then(|name| name.to_str()) else {
|
||||
for entry in sorted_dir_entries(&locales_dir)? {
|
||||
if !entry.file_type()?.is_dir() {
|
||||
continue;
|
||||
};
|
||||
let Some(language_identifier) = parse_locale(locale_name) else {
|
||||
continue;
|
||||
};
|
||||
let mut bundle = FluentBundle::new_concurrent(vec![language_identifier]);
|
||||
bundle.set_use_isolating(false);
|
||||
|
||||
for file in dir.files() {
|
||||
if file.path().extension().and_then(|value| value.to_str()) != Some("ftl") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(contents) = file.contents_utf8() else {
|
||||
panic!("locale files must be valid UTF-8");
|
||||
};
|
||||
let Ok(resource) = FluentResource::try_new(contents.to_string()) else {
|
||||
panic!("locale files must be valid Fluent resources");
|
||||
};
|
||||
if bundle.add_resource(resource).is_err() {
|
||||
panic!("locale resources must not define duplicate messages");
|
||||
}
|
||||
}
|
||||
|
||||
bundles.insert(locale_name.to_string(), bundle);
|
||||
let locale_name = entry.file_name().to_string_lossy().to_string();
|
||||
let Some(language_identifier) = parse_locale(&locale_name) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let bundle = load_bundle(&entry.path(), &locale_name, language_identifier)?;
|
||||
supported_locales.push(locale_name.clone());
|
||||
bundles.insert(locale_name, bundle);
|
||||
}
|
||||
|
||||
bundles
|
||||
supported_locales.sort();
|
||||
|
||||
Ok(LocaleResources {
|
||||
supported_locales,
|
||||
bundles,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_locales_dir() -> io::Result<PathBuf> {
|
||||
codex_utils_cargo_bin::find_resource!(LOCALES_DIR)
|
||||
}
|
||||
|
||||
fn load_bundle(
|
||||
locale_dir: &Path,
|
||||
locale_name: &str,
|
||||
language_identifier: LanguageIdentifier,
|
||||
) -> io::Result<Bundle> {
|
||||
let mut bundle = FluentBundle::new_concurrent(vec![language_identifier]);
|
||||
bundle.set_use_isolating(false);
|
||||
|
||||
for entry in sorted_dir_entries(locale_dir)? {
|
||||
if !entry.file_type()?.is_file() || entry.path().extension() != Some(OsStr::new("ftl")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let resource_path = entry.path();
|
||||
let contents = fs::read_to_string(&resource_path)?;
|
||||
let resource = FluentResource::try_new(contents).map_err(|(_, errors)| {
|
||||
io::Error::other(format!(
|
||||
"invalid Fluent resource `{}`: {errors:?}",
|
||||
resource_path.display()
|
||||
))
|
||||
})?;
|
||||
if bundle.add_resource(resource).is_err() {
|
||||
return Err(io::Error::other(format!(
|
||||
"locale `{locale_name}` defines duplicate messages in `{}`",
|
||||
resource_path.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(bundle)
|
||||
}
|
||||
|
||||
fn sorted_dir_entries(dir: &Path) -> io::Result<Vec<fs::DirEntry>> {
|
||||
let mut entries = fs::read_dir(dir)?.collect::<Result<Vec<_>, _>>()?;
|
||||
entries.sort_by_key(fs::DirEntry::file_name);
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn render_message(bundle: &Bundle, message_id: &str, args: &[(&str, &str)]) -> Option<String> {
|
||||
@@ -199,26 +243,50 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_english_message() {
|
||||
fn formats_message_with_interpolation() {
|
||||
let rendered = format_message(
|
||||
"en-US",
|
||||
"approval-question-google-docs-create-document",
|
||||
&[("connector_name", "Google Docs")],
|
||||
);
|
||||
|
||||
assert_eq!(rendered.contains("Google Docs"), true);
|
||||
assert_eq!(rendered.is_empty(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_locales_can_render_same_message() {
|
||||
let english = format_message(
|
||||
"en-US",
|
||||
"approval-question-google-docs-create-document",
|
||||
&[("connector_name", "Google Docs")],
|
||||
);
|
||||
let chinese = format_message(
|
||||
"zh-CN",
|
||||
"approval-question-google-docs-create-document",
|
||||
&[("connector_name", "Google Docs")],
|
||||
);
|
||||
|
||||
assert_eq!(english.contains("Google Docs"), true);
|
||||
assert_eq!(chinese.contains("Google Docs"), true);
|
||||
assert_ne!(english, chinese);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_message_falls_back_to_english_message() {
|
||||
let english = format_message("en-US", "approval-option-allow", &[]);
|
||||
|
||||
assert_eq!(
|
||||
format_message(
|
||||
"en-US",
|
||||
"approval-question-google-docs-create-document",
|
||||
&[("connector_name", "Google Docs")]
|
||||
),
|
||||
Some("Allow Google Docs to create a document?".to_string())
|
||||
format_message("fr-FR", "approval-option-allow", &[]),
|
||||
english
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_chinese_message() {
|
||||
fn format_message_returns_message_id_when_message_is_missing() {
|
||||
assert_eq!(
|
||||
format_message(
|
||||
"zh-CN",
|
||||
"approval-question-google-docs-create-document",
|
||||
&[("connector_name", "Google Docs")]
|
||||
),
|
||||
Some("允许 Google Docs 创建文档吗?".to_string())
|
||||
format_message("fr-FR", "approval-option-missing", &[]),
|
||||
"approval-option-missing".to_string()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
approval-header = Approve app tool call?
|
||||
approval-option-allow = Allow
|
||||
approval-option-allow-description = Run the tool and continue.
|
||||
approval-option-allow-session = Allow for this session
|
||||
approval-option-allow-session-description = Run the tool and remember this choice for this session.
|
||||
approval-option-allow-remember = Allow and don't ask me again
|
||||
approval-option-allow-remember-description = Run the tool and remember this choice for future tool calls.
|
||||
approval-option-cancel = Cancel
|
||||
approval-option-cancel-description = Cancel this tool call.
|
||||
approval-fallback-question-connector = Allow {$connector_name} to run tool "{$tool_name}"?
|
||||
approval-fallback-question-this-app = Allow this app to run tool "{$tool_name}"?
|
||||
approval-fallback-question-server = Allow the {$server} MCP server to run tool "{$tool_name}"?
|
||||
approval-monitor-question = Tool call needs your approval. Reason: {$reason}
|
||||
approval-safety-cancelled = Tool call was cancelled because of safety risks.
|
||||
approval-safety-cancelled-with-reason = Tool call was cancelled because of safety risks: {$reason}
|
||||
|
||||
approval-param-action = Action
|
||||
approval-param-attendees = Attendees
|
||||
approval-param-base-branch = Base branch
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
arc-monitor-additional-confirmation = Additional confirmation is required before this tool call can continue.
|
||||
@@ -1,3 +1,19 @@
|
||||
approval-header = 批准应用工具调用?
|
||||
approval-option-allow = 允许
|
||||
approval-option-allow-description = 运行该工具并继续。
|
||||
approval-option-allow-session = 在此会话中允许
|
||||
approval-option-allow-session-description = 运行该工具,并在此会话中记住这个选择。
|
||||
approval-option-allow-remember = 允许,不再询问
|
||||
approval-option-allow-remember-description = 运行该工具,并在未来的工具调用中记住这个选择。
|
||||
approval-option-cancel = 取消
|
||||
approval-option-cancel-description = 取消此次工具调用。
|
||||
approval-fallback-question-connector = 允许 {$connector_name} 运行工具 "{$tool_name}" 吗?
|
||||
approval-fallback-question-this-app = 允许此应用运行工具 "{$tool_name}" 吗?
|
||||
approval-fallback-question-server = 允许 {$server} MCP 服务器运行工具 "{$tool_name}" 吗?
|
||||
approval-monitor-question = 此工具调用需要你的批准。原因:{$reason}
|
||||
approval-safety-cancelled = 该工具调用因安全风险已被取消。
|
||||
approval-safety-cancelled-with-reason = 该工具调用因安全风险已被取消:{$reason}
|
||||
|
||||
approval-param-action = 操作
|
||||
approval-param-attendees = 参与者
|
||||
approval-param-base-branch = 目标分支
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
arc-monitor-additional-confirmation = 继续此工具调用前需要额外确认。
|
||||
Reference in New Issue
Block a user