Merge 67a769daf1 into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2026-04-15 22:10:00 -07:00
committed by GitHub
64 changed files with 1107 additions and 591 deletions
+1
View File
@@ -971,6 +971,7 @@ mod tests {
match ConfigBuilder::default().build().await {
Ok(config) => config,
Err(_) => Config::load_default_with_cli_overrides(Vec::new())
.await
.expect("default config should load"),
}
}
@@ -2891,6 +2891,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -13369,6 +13369,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -11213,6 +11213,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -787,6 +787,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -787,6 +787,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -930,6 +930,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1444,6 +1444,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1206,6 +1206,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1206,6 +1206,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1206,6 +1206,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1444,6 +1444,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1206,6 +1206,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1444,6 +1444,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1206,6 +1206,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1206,6 +1206,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -930,6 +930,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -930,6 +930,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -930,6 +930,12 @@
"id": {
"type": "string"
},
"mcpAppResourceUri": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -53,7 +53,7 @@ exitCode: number | null,
/**
* The duration of the command execution in milliseconds.
*/
durationMs: number | null, } | { "type": "fileChange", id: string, changes: Array<FileUpdateChange>, status: PatchApplyStatus, } | { "type": "mcpToolCall", id: string, server: string, tool: string, status: McpToolCallStatus, arguments: JsonValue, result: McpToolCallResult | null, error: McpToolCallError | null,
durationMs: number | null, } | { "type": "fileChange", id: string, changes: Array<FileUpdateChange>, status: PatchApplyStatus, } | { "type": "mcpToolCall", id: string, server: string, tool: string, status: McpToolCallStatus, arguments: JsonValue, mcpAppResourceUri?: string, result: McpToolCallResult | null, error: McpToolCallError | null,
/**
* The duration of the MCP tool call in milliseconds.
*/
@@ -764,6 +764,7 @@ macro_rules! server_notification_definitions {
Display,
ExperimentalApi,
)]
#[allow(clippy::large_enum_variant)]
#[serde(tag = "method", content = "params", rename_all = "camelCase")]
#[strum(serialize_all = "camelCase")]
pub enum ServerNotification {
@@ -502,6 +502,7 @@ impl ThreadHistoryBuilder {
.arguments
.clone()
.unwrap_or(serde_json::Value::Null),
mcp_app_resource_uri: payload.mcp_app_resource_uri.clone(),
result: None,
error: None,
duration_ms: None,
@@ -518,11 +519,11 @@ impl ThreadHistoryBuilder {
let duration_ms = i64::try_from(payload.duration.as_millis()).ok();
let (result, error) = match &payload.result {
Ok(value) => (
Some(McpToolCallResult {
Some(Box::new(McpToolCallResult {
content: value.content.clone(),
structured_content: value.structured_content.clone(),
meta: value.meta.clone(),
}),
})),
None,
),
Err(message) => (
@@ -542,6 +543,7 @@ impl ThreadHistoryBuilder {
.arguments
.clone()
.unwrap_or(serde_json::Value::Null),
mcp_app_resource_uri: payload.mcp_app_resource_uri.clone(),
result,
error,
duration_ms,
@@ -1809,6 +1811,7 @@ mod tests {
tool: "lookup".into(),
arguments: Some(serde_json::json!({"id":"123"})),
},
mcp_app_resource_uri: None,
duration: Duration::from_millis(8),
result: Err("boom".into()),
}),
@@ -1857,6 +1860,7 @@ mod tests {
tool: "lookup".into(),
status: McpToolCallStatus::Failed,
arguments: serde_json::json!({"id":"123"}),
mcp_app_resource_uri: None,
result: None,
error: Some(McpToolCallError {
message: "boom".into(),
@@ -1882,6 +1886,7 @@ mod tests {
tool: "lookup".into(),
arguments: Some(serde_json::json!({"id":"123"})),
},
mcp_app_resource_uri: Some("ui://widget/lookup.html".into()),
duration: Duration::from_millis(8),
result: Ok(CallToolResult {
content: vec![serde_json::json!({
@@ -1911,7 +1916,8 @@ mod tests {
tool: "lookup".into(),
status: McpToolCallStatus::Completed,
arguments: serde_json::json!({"id":"123"}),
result: Some(McpToolCallResult {
mcp_app_resource_uri: Some("ui://widget/lookup.html".into()),
result: Some(Box::new(McpToolCallResult {
content: vec![serde_json::json!({
"type": "text",
"text": "result"
@@ -1920,7 +1926,7 @@ mod tests {
meta: Some(serde_json::json!({
"ui/resourceUri": "ui://widget/lookup.html"
})),
}),
})),
error: None,
duration_ms: Some(8),
}
@@ -4585,7 +4585,10 @@ pub enum ThreadItem {
tool: String,
status: McpToolCallStatus,
arguments: JsonValue,
result: Option<McpToolCallResult>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
mcp_app_resource_uri: Option<String>,
result: Option<Box<McpToolCallResult>>,
error: Option<McpToolCallError>,
/// The duration of the MCP tool call in milliseconds.
#[ts(type = "number | null")]
@@ -2910,6 +2910,7 @@ async fn construct_mcp_tool_call_notification(
tool: begin_event.invocation.tool,
status: McpToolCallStatus::InProgress,
arguments: begin_event.invocation.arguments.unwrap_or(JsonValue::Null),
mcp_app_resource_uri: begin_event.mcp_app_resource_uri,
result: None,
error: None,
duration_ms: None,
@@ -2936,11 +2937,11 @@ async fn construct_mcp_tool_call_end_notification(
let (result, error) = match &end_event.result {
Ok(value) => (
Some(McpToolCallResult {
Some(Box::new(McpToolCallResult {
content: value.content.clone(),
structured_content: value.structured_content.clone(),
meta: value.meta.clone(),
}),
})),
None,
),
Err(message) => (
@@ -2957,6 +2958,7 @@ async fn construct_mcp_tool_call_end_notification(
tool: end_event.invocation.tool,
status,
arguments: end_event.invocation.arguments.unwrap_or(JsonValue::Null),
mcp_app_resource_uri: end_event.mcp_app_resource_uri,
result,
error,
duration_ms,
@@ -4219,6 +4221,7 @@ mod tests {
tool: "list_mcp_resources".to_string(),
arguments: Some(serde_json::json!({"server": ""})),
},
mcp_app_resource_uri: Some("ui://widget/list-resources.html".to_string()),
};
let thread_id = ThreadId::new().to_string();
@@ -4239,6 +4242,7 @@ mod tests {
tool: begin_event.invocation.tool,
status: McpToolCallStatus::InProgress,
arguments: serde_json::json!({"server": ""}),
mcp_app_resource_uri: Some("ui://widget/list-resources.html".to_string()),
result: None,
error: None,
duration_ms: None,
@@ -4379,6 +4383,7 @@ mod tests {
tool: "list_mcp_resources".to_string(),
arguments: None,
},
mcp_app_resource_uri: None,
};
let thread_id = ThreadId::new().to_string();
@@ -4399,6 +4404,7 @@ mod tests {
tool: begin_event.invocation.tool,
status: McpToolCallStatus::InProgress,
arguments: JsonValue::Null,
mcp_app_resource_uri: None,
result: None,
error: None,
duration_ms: None,
@@ -4430,6 +4436,7 @@ mod tests {
tool: "list_mcp_resources".to_string(),
arguments: Some(serde_json::json!({"server": ""})),
},
mcp_app_resource_uri: Some("ui://widget/list-resources.html".to_string()),
duration: Duration::from_nanos(92708),
result: Ok(result),
};
@@ -4452,13 +4459,14 @@ mod tests {
tool: end_event.invocation.tool,
status: McpToolCallStatus::Completed,
arguments: serde_json::json!({"server": ""}),
result: Some(McpToolCallResult {
mcp_app_resource_uri: Some("ui://widget/list-resources.html".to_string()),
result: Some(Box::new(McpToolCallResult {
content,
structured_content: None,
meta: Some(serde_json::json!({
"ui/resourceUri": "ui://widget/list-resources.html"
})),
}),
})),
error: None,
duration_ms: Some(0),
},
@@ -4476,6 +4484,7 @@ mod tests {
tool: "list_mcp_resources".to_string(),
arguments: None,
},
mcp_app_resource_uri: None,
duration: Duration::from_millis(1),
result: Err("boom".to_string()),
};
@@ -4498,6 +4507,7 @@ mod tests {
tool: end_event.invocation.tool,
status: McpToolCallStatus::Failed,
arguments: JsonValue::Null,
mcp_app_resource_uri: None,
result: None,
error: Some(McpToolCallError {
message: "boom".to_string(),
@@ -2442,6 +2442,7 @@ impl CodexMessageProcessor {
))
{
let trust_target = resolve_root_git_project_for_trust(config.cwd.as_path())
.await
.unwrap_or_else(|| config.cwd.to_path_buf());
let cli_overrides_with_trust;
let cli_overrides_for_reload = if let Err(err) =
+1
View File
@@ -716,6 +716,7 @@ mod tests {
match ConfigBuilder::default().build().await {
Ok(config) => config,
Err(_) => Config::load_default_with_cli_overrides(Vec::new())
.await
.expect("default config should load"),
}
}
+8 -6
View File
@@ -432,12 +432,14 @@ pub async fn run_main_with_transport(
Err(err) => {
let message = config_warning_from_error("Invalid configuration; using defaults.", &err);
config_warnings.push(message);
Config::load_default_with_cli_overrides(cli_kv_overrides.clone()).map_err(|e| {
std::io::Error::new(
ErrorKind::InvalidData,
format!("error loading default config after config error: {e}"),
)
})?
Config::load_default_with_cli_overrides(cli_kv_overrides.clone())
.await
.map_err(|e| {
std::io::Error::new(
ErrorKind::InvalidData,
format!("error loading default config after config error: {e}"),
)
})?
}
};
@@ -411,11 +411,14 @@ async fn command_exec_streaming_does_not_buffer_output() -> Result<()> {
})
.await?;
let delta = read_command_exec_delta(&mut mcp).await?;
assert_eq!(delta.process_id, process_id.as_str());
assert_eq!(delta.stream, CommandExecOutputStream::Stdout);
assert_eq!(STANDARD.decode(&delta.delta_base64)?, b"abcde");
assert!(delta.cap_reached);
let output = collect_command_exec_output_until(
CommandExecDeltaReader::Mcp(&mut mcp),
process_id.as_str(),
"capped stdout",
|_output, delta| delta.stream == CommandExecOutputStream::Stdout && delta.cap_reached,
)
.await?;
assert_eq!(output.stdout, "abcde");
let terminate_request_id = mcp
.send_command_exec_terminate_request(CommandExecTerminateParams {
process_id: process_id.clone(),
@@ -471,21 +474,13 @@ async fn command_exec_pipe_streams_output_and_accepts_write() -> Result<()> {
})
.await?;
let first_stdout = read_command_exec_delta(&mut mcp).await?;
let first_stderr = read_command_exec_delta(&mut mcp).await?;
let seen = [first_stdout, first_stderr];
assert!(
seen.iter()
.all(|delta| delta.process_id == process_id.as_str())
);
assert!(seen.iter().any(|delta| {
delta.stream == CommandExecOutputStream::Stdout
&& delta.delta_base64 == STANDARD.encode("out-start\n")
}));
assert!(seen.iter().any(|delta| {
delta.stream == CommandExecOutputStream::Stderr
&& delta.delta_base64 == STANDARD.encode("err-start\n")
}));
wait_for_command_exec_outputs_contains(
&mut mcp,
process_id.as_str(),
"out-start\n",
"err-start\n",
)
.await?;
let write_request_id = mcp
.send_command_exec_write_request(CommandExecWriteParams {
@@ -499,21 +494,13 @@ async fn command_exec_pipe_streams_output_and_accepts_write() -> Result<()> {
.await?;
assert_eq!(write_response.result, serde_json::json!({}));
let next_delta = read_command_exec_delta(&mut mcp).await?;
let final_delta = read_command_exec_delta(&mut mcp).await?;
let seen = [next_delta, final_delta];
assert!(
seen.iter()
.all(|delta| delta.process_id == process_id.as_str())
);
assert!(seen.iter().any(|delta| {
delta.stream == CommandExecOutputStream::Stdout
&& delta.delta_base64 == STANDARD.encode("out:hello\n")
}));
assert!(seen.iter().any(|delta| {
delta.stream == CommandExecOutputStream::Stderr
&& delta.delta_base64 == STANDARD.encode("err:hello\n")
}));
wait_for_command_exec_outputs_contains(
&mut mcp,
process_id.as_str(),
"out:hello\n",
"err:hello\n",
)
.await?;
let response = mcp
.read_stream_until_response_message(RequestId::Integer(command_request_id))
@@ -562,17 +549,13 @@ async fn command_exec_tty_implies_streaming_and_reports_pty_output() -> Result<(
})
.await?;
let started_text = read_command_exec_output_until_contains(
wait_for_command_exec_output_contains(
&mut mcp,
process_id.as_str(),
CommandExecOutputStream::Stdout,
"tty\n",
)
.await?;
assert!(
started_text.contains("tty\n"),
"expected TTY startup output, got {started_text:?}"
);
let write_request_id = mcp
.send_command_exec_write_request(CommandExecWriteParams {
@@ -586,17 +569,13 @@ async fn command_exec_tty_implies_streaming_and_reports_pty_output() -> Result<(
.await?;
assert_eq!(write_response.result, serde_json::json!({}));
let echoed_text = read_command_exec_output_until_contains(
wait_for_command_exec_output_contains(
&mut mcp,
process_id.as_str(),
CommandExecOutputStream::Stdout,
"echo:world\n",
)
.await?;
assert!(
echoed_text.contains("echo:world\n"),
"expected TTY echo output, got {echoed_text:?}"
);
let response = mcp
.read_stream_until_response_message(RequestId::Integer(command_request_id))
@@ -643,17 +622,13 @@ async fn command_exec_tty_supports_initial_size_and_resize() -> Result<()> {
})
.await?;
let started_text = read_command_exec_output_until_contains(
wait_for_command_exec_output_contains(
&mut mcp,
process_id.as_str(),
CommandExecOutputStream::Stdout,
"start:31 101\n",
)
.await?;
assert!(
started_text.contains("start:31 101\n"),
"unexpected initial size output: {started_text:?}"
);
let resize_request_id = mcp
.send_command_exec_resize_request(CommandExecResizeParams {
@@ -681,17 +656,13 @@ async fn command_exec_tty_supports_initial_size_and_resize() -> Result<()> {
.await?;
assert_eq!(write_response.result, serde_json::json!({}));
let resized_text = read_command_exec_output_until_contains(
wait_for_command_exec_output_contains(
&mut mcp,
process_id.as_str(),
CommandExecOutputStream::Stdout,
"after:45 132\n",
)
.await?;
assert!(
resized_text.contains("after:45 132\n"),
"unexpected resized output: {resized_text:?}"
);
let response = mcp
.read_stream_until_response_message(RequestId::Integer(command_request_id))
@@ -744,11 +715,13 @@ async fn command_exec_process_ids_are_connection_scoped_and_disconnect_terminate
)
.await?;
let delta = read_command_exec_delta_ws(&mut ws1).await?;
assert_eq!(delta.process_id, "shared-process");
assert_eq!(delta.stream, CommandExecOutputStream::Stdout);
let delta_text = String::from_utf8(STANDARD.decode(&delta.delta_base64)?)?;
assert!(delta_text.contains("ready"));
collect_command_exec_output_until(
CommandExecDeltaReader::Websocket(&mut ws1),
"shared-process",
"websocket ready output",
|output, _delta| output.stdout.contains("ready\n"),
)
.await?;
wait_for_process_marker(&marker, /*should_exist*/ true).await?;
send_request(
@@ -796,31 +769,98 @@ async fn read_command_exec_delta(
decode_delta_notification(notification)
}
async fn read_command_exec_output_until_contains(
async fn wait_for_command_exec_output_contains(
mcp: &mut McpProcess,
process_id: &str,
stream: CommandExecOutputStream,
expected: &str,
) -> Result<String> {
) -> Result<()> {
let stream_name = match stream {
CommandExecOutputStream::Stdout => "stdout",
CommandExecOutputStream::Stderr => "stderr",
};
collect_command_exec_output_until(
CommandExecDeltaReader::Mcp(mcp),
process_id,
format!("{stream_name} containing {expected:?}"),
|output, _delta| match stream {
CommandExecOutputStream::Stdout => output.stdout.contains(expected),
CommandExecOutputStream::Stderr => output.stderr.contains(expected),
},
)
.await?;
Ok(())
}
async fn wait_for_command_exec_outputs_contains(
mcp: &mut McpProcess,
process_id: &str,
stdout_expected: &str,
stderr_expected: &str,
) -> Result<()> {
collect_command_exec_output_until(
CommandExecDeltaReader::Mcp(mcp),
process_id,
format!("stdout containing {stdout_expected:?} and stderr containing {stderr_expected:?}"),
|output, _delta| {
output.stdout.contains(stdout_expected) && output.stderr.contains(stderr_expected)
},
)
.await?;
Ok(())
}
enum CommandExecDeltaReader<'a> {
Mcp(&'a mut McpProcess),
Websocket(&'a mut super::connection_handling_websocket::WsClient),
}
#[derive(Default)]
struct CollectedCommandExecOutput {
stdout: String,
stderr: String,
}
async fn collect_command_exec_output_until(
mut reader: CommandExecDeltaReader<'_>,
process_id: &str,
waiting_for: impl Into<String>,
mut should_stop: impl FnMut(
&CollectedCommandExecOutput,
&CommandExecOutputDeltaNotification,
) -> bool,
) -> Result<CollectedCommandExecOutput> {
let waiting_for = waiting_for.into();
let deadline = Instant::now() + DEFAULT_READ_TIMEOUT;
let mut collected = String::new();
let mut output = CollectedCommandExecOutput::default();
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
let delta = timeout(remaining, read_command_exec_delta(mcp))
.await
.with_context(|| {
format!(
"timed out waiting for {expected:?} in command/exec output for {process_id}; collected {collected:?}"
)
})??;
let delta = timeout(remaining, async {
match &mut reader {
CommandExecDeltaReader::Mcp(mcp) => read_command_exec_delta(mcp).await,
CommandExecDeltaReader::Websocket(stream) => {
read_command_exec_delta_ws(stream).await
}
}
})
.await
.with_context(|| {
format!(
"timed out waiting for {waiting_for} in command/exec output for {process_id}; collected stdout={:?}, stderr={:?}",
output.stdout, output.stderr
)
})??;
assert_eq!(delta.process_id, process_id);
assert_eq!(delta.stream, stream);
let delta_text = String::from_utf8(STANDARD.decode(&delta.delta_base64)?)?;
collected.push_str(&delta_text.replace('\r', ""));
if collected.contains(expected) {
return Ok(collected);
let delta_text = delta_text.replace('\r', "");
match delta.stream {
CommandExecOutputStream::Stdout => output.stdout.push_str(&delta_text),
CommandExecOutputStream::Stderr => output.stderr.push_str(&delta_text),
}
if should_stop(&output, &delta) {
return Ok(output);
}
}
}
@@ -717,6 +717,7 @@ model_reasoning_effort = "high"
let config_toml = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
let trusted_root = resolve_root_git_project_for_trust(workspace.path())
.await
.unwrap_or_else(|| workspace.path().to_path_buf());
assert!(config_toml.contains(&persisted_trust_path(&trusted_root)));
assert!(config_toml.contains("trust_level = \"trusted\""));
@@ -753,8 +754,9 @@ async fn thread_start_with_nested_git_cwd_trusts_repo_root() -> Result<()> {
.await??;
let config_toml = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
let trusted_root =
resolve_root_git_project_for_trust(&nested).expect("git root should resolve");
let trusted_root = resolve_root_git_project_for_trust(&nested)
.await
.expect("git root should resolve");
assert!(config_toml.contains(&persisted_trust_path(&trusted_root)));
assert!(!config_toml.contains(&persisted_trust_path(&nested)));
+9 -6
View File
@@ -596,7 +596,7 @@ pub struct GhostSnapshotToml {
impl ConfigToml {
/// Derive the effective sandbox policy from the configuration.
pub fn derive_sandbox_policy(
pub async fn derive_sandbox_policy(
&self,
sandbox_mode_override: Option<SandboxMode>,
profile_sandbox_mode: Option<SandboxMode>,
@@ -610,11 +610,13 @@ impl ConfigToml {
let resolved_sandbox_mode = sandbox_mode_override
.or(profile_sandbox_mode)
.or(self.sandbox_mode)
.or_else(|| {
.or(if sandbox_mode_was_explicit {
None
} else {
// If no sandbox_mode is set but this directory has a trust decision,
// default to workspace-write except on unsandboxed Windows where we
// default to read-only.
self.get_active_project(resolved_cwd).and_then(|p| {
self.get_active_project(resolved_cwd).await.and_then(|p| {
if p.is_trusted() || p.is_untrusted() {
if cfg!(target_os = "windows")
&& windows_sandbox_level == WindowsSandboxLevel::Disabled
@@ -676,7 +678,8 @@ impl ConfigToml {
/// Resolves the cwd to an existing project, or returns None if ConfigToml
/// does not contain a project corresponding to cwd or a git repo for cwd
pub fn get_active_project(&self, resolved_cwd: &Path) -> Option<ProjectConfig> {
pub async fn get_active_project(&self, resolved_cwd: &Path) -> Option<ProjectConfig> {
let repo_root = resolve_root_git_project_for_trust(resolved_cwd).await;
let projects = self.projects.clone().unwrap_or_default();
let resolved_cwd_key = project_trust_key(resolved_cwd);
@@ -691,8 +694,8 @@ impl ConfigToml {
// If cwd lives inside a git repo/worktree, check whether the root git project
// (the primary repository working directory) is trusted. This lets
// worktrees inherit trust from the main project.
if let Some(repo_root) = resolve_root_git_project_for_trust(resolved_cwd) {
let repo_root_key = project_trust_key(repo_root.as_path());
if let Some(repo_root) = repo_root.as_deref() {
let repo_root_key = project_trust_key(repo_root);
let repo_root_raw_key = repo_root.to_string_lossy().to_string();
if let Some(project_config_for_root) = projects
.get(&repo_root_key)
+5 -3
View File
@@ -78,7 +78,8 @@ async fn apply_role_to_config_inner(
role_layer_toml,
preserve_current_profile,
preserve_current_provider,
)?;
)
.await?;
Ok(())
}
@@ -150,7 +151,7 @@ fn preservation_policy(config: &Config, role_layer_toml: &TomlValue) -> (bool, b
mod reload {
use super::*;
pub(super) fn build_next_config(
pub(super) async fn build_next_config(
config: &Config,
role_layer_toml: TomlValue,
preserve_current_profile: bool,
@@ -171,7 +172,8 @@ mod reload {
reload_overrides(config, preserve_current_provider),
config.codex_home.clone(),
config_layer_stack,
)?;
)
.await?;
if preserve_current_profile {
next_config.active_profile = config.active_profile.clone();
}
+15 -15
View File
@@ -457,8 +457,8 @@ fn numbered_mcp_tools(count: usize) -> HashMap<String, ToolInfo> {
.collect()
}
fn tools_config_for_mcp_tool_exposure(search_tool: bool) -> ToolsConfig {
let config = test_config();
async fn tools_config_for_mcp_tool_exposure(search_tool: bool) -> ToolsConfig {
let config = test_config().await;
let model_info = ModelsManager::construct_model_info_offline_for_tests(
"gpt-5-codex",
&config.to_models_manager_config(),
@@ -870,7 +870,7 @@ async fn get_base_instructions_no_user_content() {
];
let (session, _turn_context) = make_session_and_context().await;
let config = test_config();
let config = test_config().await;
for test_case in test_cases {
let model_info = model_info_for_slug(test_case.slug, &config);
@@ -1035,10 +1035,10 @@ fn collect_explicit_app_ids_from_skill_items_skips_plain_mentions_with_skill_con
assert_eq!(connector_ids, HashSet::<String>::new());
}
#[test]
fn mcp_tool_exposure_directly_exposes_small_effective_tool_sets() {
let config = test_config();
let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true);
#[tokio::test]
async fn mcp_tool_exposure_directly_exposes_small_effective_tool_sets() {
let config = test_config().await;
let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true).await;
let mcp_tools = numbered_mcp_tools(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD - 1);
let exposure = build_mcp_tool_exposure(
@@ -1057,10 +1057,10 @@ fn mcp_tool_exposure_directly_exposes_small_effective_tool_sets() {
assert!(exposure.deferred_tools.is_none());
}
#[test]
fn mcp_tool_exposure_searches_large_effective_tool_sets() {
let config = test_config();
let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true);
#[tokio::test]
async fn mcp_tool_exposure_searches_large_effective_tool_sets() {
let config = test_config().await;
let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true).await;
let mcp_tools = numbered_mcp_tools(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD);
let exposure = build_mcp_tool_exposure(
@@ -1083,10 +1083,10 @@ fn mcp_tool_exposure_searches_large_effective_tool_sets() {
assert_eq!(deferred_tool_names, expected_tool_names);
}
#[test]
fn mcp_tool_exposure_directly_exposes_explicit_apps_without_deferred_overlap() {
let config = test_config();
let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true);
#[tokio::test]
async fn mcp_tool_exposure_directly_exposes_explicit_apps_without_deferred_overlap() {
let config = test_config().await;
let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true).await;
let mut mcp_tools = numbered_mcp_tools(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD - 1);
mcp_tools.extend([(
"mcp__codex_apps__calendar_create_event".to_string(),
File diff suppressed because it is too large Load Diff
+24 -13
View File
@@ -169,13 +169,14 @@ fn resolve_mcp_oauth_credentials_store_mode(
}
#[cfg(test)]
pub(crate) fn test_config() -> Config {
pub(crate) async fn test_config() -> Config {
let codex_home = tempfile::tempdir().expect("create temp dir");
Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),
AbsolutePathBuf::from_absolute_path(codex_home.path()).expect("temp dir should resolve"),
)
.await
.expect("load default test config")
}
@@ -726,6 +727,7 @@ impl ConfigBuilder {
codex_home,
config_layer_stack,
)
.await
}
#[cfg(test)]
@@ -786,7 +788,7 @@ impl Config {
}
/// Load a default configuration when user config files are invalid.
pub fn load_default_with_cli_overrides(
pub async fn load_default_with_cli_overrides(
cli_overrides: Vec<(String, TomlValue)>,
) -> std::io::Result<Self> {
let codex_home = find_codex_home()?;
@@ -794,11 +796,12 @@ impl Config {
codex_home.to_path_buf(),
cli_overrides,
)
.await
}
/// Load a default configuration for a specific Codex home without reading
/// user, project, or system config layers.
pub fn load_default_with_cli_overrides_for_codex_home(
pub async fn load_default_with_cli_overrides_for_codex_home(
codex_home: PathBuf,
cli_overrides: Vec<(String, TomlValue)>,
) -> std::io::Result<Self> {
@@ -818,6 +821,7 @@ impl Config {
codex_home,
ConfigLayerStack::default(),
)
.await
}
/// This is a secondary way of creating [Config], which is appropriate when
@@ -1413,22 +1417,24 @@ pub(crate) fn resolve_web_search_mode_for_turn(
impl Config {
#[cfg(test)]
fn load_from_base_config_with_overrides(
async fn load_from_base_config_with_overrides(
cfg: ConfigToml,
overrides: ConfigOverrides,
codex_home: AbsolutePathBuf,
) -> std::io::Result<Self> {
// Note this ignores requirements.toml enforcement for tests.
let config_layer_stack = ConfigLayerStack::default();
Self::load_config_with_layer_stack(cfg, overrides, codex_home, config_layer_stack)
Self::load_config_with_layer_stack(cfg, overrides, codex_home, config_layer_stack).await
}
pub(crate) fn load_config_with_layer_stack(
pub(crate) async fn load_config_with_layer_stack(
cfg: ConfigToml,
overrides: ConfigOverrides,
codex_home: AbsolutePathBuf,
config_layer_stack: ConfigLayerStack,
) -> std::io::Result<Self> {
// Keep the large config-construction future off small test thread stacks.
Box::pin(async move {
validate_model_providers(&cfg.model_providers)
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
// Ensure that every field of ConfigRequirements is applied to the final
@@ -1547,6 +1553,7 @@ impl Config {
.collect();
let active_project = cfg
.get_active_project(resolved_cwd.as_path())
.await
.unwrap_or(ProjectConfig { trust_level: None });
let permission_config_syntax = resolve_permission_config_syntax(
&config_layer_stack,
@@ -1636,13 +1643,15 @@ impl Config {
)
} else {
let configured_network_proxy_config = NetworkProxyConfig::default();
let mut sandbox_policy = cfg.derive_sandbox_policy(
sandbox_mode,
config_profile.sandbox_mode,
windows_sandbox_level,
resolved_cwd.as_path(),
Some(&constrained_sandbox_policy),
);
let mut sandbox_policy = cfg
.derive_sandbox_policy(
sandbox_mode,
config_profile.sandbox_mode,
windows_sandbox_level,
resolved_cwd.as_path(),
Some(&constrained_sandbox_policy),
)
.await;
if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = &mut sandbox_policy {
for path in &additional_writable_roots {
if !writable_roots.iter().any(|existing| existing == path) {
@@ -2207,6 +2216,8 @@ impl Config {
},
};
Ok(config)
})
.await
}
fn load_instructions(codex_dir: Option<&AbsolutePathBuf>) -> Option<LoadedUserInstructions> {
@@ -24,8 +24,8 @@ fn normalize_absolute_path_for_platform_simplifies_windows_verbatim_paths() {
assert_eq!(parsed, PathBuf::from(r"D:\c\x\worktrees\2508\swift-base"));
}
#[test]
fn restricted_read_implicitly_allows_helper_executables() -> std::io::Result<()> {
#[tokio::test]
async fn restricted_read_implicitly_allows_helper_executables() -> std::io::Result<()> {
let temp_dir = TempDir::new()?;
let cwd = temp_dir.path().join("workspace");
let codex_home = temp_dir.path().join(".codex");
@@ -64,7 +64,8 @@ fn restricted_read_implicitly_allows_helper_executables() -> std::io::Result<()>
..Default::default()
},
AbsolutePathBuf::from_absolute_path(&codex_home)?,
)?;
)
.await?;
let expected_zsh = AbsolutePathBuf::try_from(zsh_path)?;
let expected_allowed_arg0_dir = AbsolutePathBuf::try_from(allowed_arg0_dir)?;
+1 -1
View File
@@ -650,7 +650,7 @@ async fn project_trust_context(
let projects = project_trust_config.projects.unwrap_or_default();
let project_root_key = project_trust_key(project_root.as_path());
let repo_root = resolve_root_git_project_for_trust(cwd.as_path());
let repo_root = resolve_root_git_project_for_trust(cwd.as_path()).await;
let repo_root_key = repo_root.as_ref().map(|root| project_trust_key(root));
let projects_trust = projects
+30 -17
View File
@@ -426,10 +426,14 @@ async fn test_get_git_working_tree_state_branch_fallback() {
assert_eq!(state.sha, GitSha::new(&remote_sha));
}
#[test]
fn resolve_root_git_project_for_trust_returns_none_outside_repo() {
#[tokio::test]
async fn resolve_root_git_project_for_trust_returns_none_outside_repo() {
let tmp = TempDir::new().expect("tempdir");
assert!(resolve_root_git_project_for_trust(tmp.path()).is_none());
assert!(
resolve_root_git_project_for_trust(tmp.path())
.await
.is_none()
);
}
#[tokio::test]
@@ -439,12 +443,15 @@ async fn resolve_root_git_project_for_trust_regular_repo_returns_repo_root() {
let expected = std::fs::canonicalize(&repo_path).unwrap();
assert_eq!(
resolve_root_git_project_for_trust(&repo_path),
resolve_root_git_project_for_trust(&repo_path).await,
Some(expected.clone())
);
let nested = repo_path.join("sub/dir");
std::fs::create_dir_all(&nested).unwrap();
assert_eq!(resolve_root_git_project_for_trust(&nested), Some(expected));
assert_eq!(
resolve_root_git_project_for_trust(&nested).await,
Some(expected)
);
}
#[tokio::test]
@@ -467,18 +474,20 @@ async fn resolve_root_git_project_for_trust_detects_worktree_and_returns_main_ro
.expect("git worktree add");
let expected = std::fs::canonicalize(&repo_path).ok();
let got =
resolve_root_git_project_for_trust(&wt_root).and_then(|p| std::fs::canonicalize(p).ok());
let got = resolve_root_git_project_for_trust(&wt_root)
.await
.and_then(|p| std::fs::canonicalize(p).ok());
assert_eq!(got, expected);
let nested = wt_root.join("nested/sub");
std::fs::create_dir_all(&nested).unwrap();
let got_nested =
resolve_root_git_project_for_trust(&nested).and_then(|p| std::fs::canonicalize(p).ok());
let got_nested = resolve_root_git_project_for_trust(&nested)
.await
.and_then(|p| std::fs::canonicalize(p).ok());
assert_eq!(got_nested, expected);
}
#[test]
fn resolve_root_git_project_for_trust_detects_worktree_pointer_without_git_command() {
#[tokio::test]
async fn resolve_root_git_project_for_trust_detects_worktree_pointer_without_git_command() {
let tmp = TempDir::new().expect("tempdir");
let repo_root = tmp.path().join("repo");
let common_dir = repo_root.join(".git");
@@ -495,17 +504,17 @@ fn resolve_root_git_project_for_trust_detects_worktree_pointer_without_git_comma
let expected = std::fs::canonicalize(&repo_root).unwrap();
assert_eq!(
resolve_root_git_project_for_trust(&worktree_root),
resolve_root_git_project_for_trust(&worktree_root).await,
Some(expected.clone())
);
assert_eq!(
resolve_root_git_project_for_trust(&worktree_root.join("nested")),
resolve_root_git_project_for_trust(&worktree_root.join("nested")).await,
Some(expected)
);
}
#[test]
fn resolve_root_git_project_for_trust_non_worktrees_gitdir_returns_none() {
#[tokio::test]
async fn resolve_root_git_project_for_trust_non_worktrees_gitdir_returns_none() {
let tmp = TempDir::new().expect("tempdir");
let proj = tmp.path().join("proj");
std::fs::create_dir_all(proj.join("nested")).unwrap();
@@ -520,8 +529,12 @@ fn resolve_root_git_project_for_trust_non_worktrees_gitdir_returns_none() {
)
.unwrap();
assert!(resolve_root_git_project_for_trust(&proj).is_none());
assert!(resolve_root_git_project_for_trust(&proj.join("nested")).is_none());
assert!(resolve_root_git_project_for_trust(&proj).await.is_none());
assert!(
resolve_root_git_project_for_trust(&proj.join("nested"))
.await
.is_none()
);
}
#[tokio::test]
+6 -6
View File
@@ -823,9 +823,9 @@ async fn interrupt_and_drain_turn(codex: &Codex) -> anyhow::Result<()> {
mod tests {
use super::*;
#[test]
fn guardian_review_session_config_change_invalidates_cached_session() {
let parent_config = crate::config::test_config();
#[tokio::test]
async fn guardian_review_session_config_change_invalidates_cached_session() {
let parent_config = crate::config::test_config().await;
let cached_spawn_config = build_guardian_review_session_config(
&parent_config,
/*live_network_config*/ None,
@@ -855,9 +855,9 @@ mod tests {
);
}
#[test]
fn guardian_review_session_config_disables_hooks() {
let mut parent_config = crate::config::test_config();
#[tokio::test]
async fn guardian_review_session_config_disables_hooks() {
let mut parent_config = crate::config::test_config().await;
parent_config
.features
.enable(Feature::CodexHooks)
+24 -21
View File
@@ -1332,8 +1332,8 @@ async fn guardian_review_surfaces_responses_api_errors_in_rejection_reason() ->
Ok(())
}
#[test]
fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> anyhow::Result<()> {
#[tokio::test]
async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> anyhow::Result<()> {
const TEST_STACK_SIZE_BYTES: usize = 2 * 1024 * 1024;
let handle =
@@ -1569,9 +1569,9 @@ fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> anyhow:
)),
}
}
#[test]
fn guardian_review_session_config_preserves_parent_network_proxy() {
let mut parent_config = test_config();
#[tokio::test]
async fn guardian_review_session_config_preserves_parent_network_proxy() {
let mut parent_config = test_config().await;
let network = NetworkProxySpec::from_config_and_constraints(
NetworkProxyConfig::default(),
Some(NetworkConstraints {
@@ -1616,9 +1616,9 @@ fn guardian_review_session_config_preserves_parent_network_proxy() {
);
}
#[test]
fn guardian_review_session_config_overrides_parent_developer_instructions() {
let mut parent_config = test_config();
#[tokio::test]
async fn guardian_review_session_config_overrides_parent_developer_instructions() {
let mut parent_config = test_config().await;
parent_config.developer_instructions =
Some("parent or managed config should not replace guardian policy".to_string());
@@ -1636,9 +1636,9 @@ fn guardian_review_session_config_overrides_parent_developer_instructions() {
);
}
#[test]
fn guardian_review_session_config_uses_live_network_proxy_state() {
let mut parent_config = test_config();
#[tokio::test]
async fn guardian_review_session_config_uses_live_network_proxy_state() {
let mut parent_config = test_config().await;
let mut parent_network = NetworkProxyConfig::default();
parent_network.network.enabled = true;
parent_network
@@ -1680,9 +1680,9 @@ fn guardian_review_session_config_uses_live_network_proxy_state() {
);
}
#[test]
fn guardian_review_session_config_rejects_pinned_collab_feature() {
let mut parent_config = test_config();
#[tokio::test]
async fn guardian_review_session_config_rejects_pinned_collab_feature() {
let mut parent_config = test_config().await;
parent_config.features = ManagedFeatures::from_configured(
parent_config.features.get().clone(),
Some(Sourced {
@@ -1708,9 +1708,9 @@ fn guardian_review_session_config_rejects_pinned_collab_feature() {
);
}
#[test]
fn guardian_review_session_config_uses_parent_active_model_instead_of_hardcoded_slug() {
let mut parent_config = test_config();
#[tokio::test]
async fn guardian_review_session_config_uses_parent_active_model_instead_of_hardcoded_slug() {
let mut parent_config = test_config().await;
parent_config.model = Some("configured-model".to_string());
let guardian_config = build_guardian_review_session_config_for_test(
@@ -1724,8 +1724,8 @@ fn guardian_review_session_config_uses_parent_active_model_instead_of_hardcoded_
assert_eq!(guardian_config.model, Some("active-model".to_string()));
}
#[test]
fn guardian_review_session_config_uses_requirements_guardian_policy_config() {
#[tokio::test]
async fn guardian_review_session_config_uses_requirements_guardian_policy_config() {
let codex_home = tempfile::tempdir().expect("create temp dir");
let workspace = tempfile::tempdir().expect("create temp dir");
let config_layer_stack = ConfigLayerStack::new(
@@ -1748,6 +1748,7 @@ fn guardian_review_session_config_uses_requirements_guardian_policy_config() {
codex_home.abs(),
config_layer_stack,
)
.await
.expect("load config");
let guardian_config = build_guardian_review_session_config_for_test(
@@ -1766,8 +1767,9 @@ fn guardian_review_session_config_uses_requirements_guardian_policy_config() {
);
}
#[test]
fn guardian_review_session_config_uses_default_guardian_policy_without_requirements_override() {
#[tokio::test]
async fn guardian_review_session_config_uses_default_guardian_policy_without_requirements_override()
{
let codex_home = tempfile::tempdir().expect("create temp dir");
let workspace = tempfile::tempdir().expect("create temp dir");
let config_layer_stack =
@@ -1782,6 +1784,7 @@ fn guardian_review_session_config_uses_default_guardian_policy_without_requireme
codex_home.abs(),
config_layer_stack,
)
.await
.expect("load config");
let guardian_config = build_guardian_review_session_config_for_test(
+37
View File
@@ -101,6 +101,9 @@ pub(crate) async fn handle_mcp_tool_call(
let metadata =
lookup_mcp_tool_metadata(sess.as_ref(), turn_context.as_ref(), &server, &tool_name).await;
let mcp_app_resource_uri = metadata
.as_ref()
.and_then(|metadata| metadata.mcp_app_resource_uri.clone());
let app_tool_policy = if server == CODEX_APPS_MCP_SERVER_NAME {
connectors::app_tool_policy(
&turn_context.config,
@@ -130,6 +133,7 @@ pub(crate) async fn handle_mcp_tool_call(
turn_context.as_ref(),
&call_id,
invocation,
mcp_app_resource_uri.clone(),
"MCP tool call blocked by app configuration".to_string(),
/*already_started*/ false,
)
@@ -161,6 +165,7 @@ pub(crate) async fn handle_mcp_tool_call(
let tool_call_begin_event = EventMsg::McpToolCallBegin(McpToolCallBeginEvent {
call_id: call_id.clone(),
invocation: invocation.clone(),
mcp_app_resource_uri: mcp_app_resource_uri.clone(),
});
notify_mcp_tool_call_event(sess.as_ref(), turn_context.as_ref(), tool_call_begin_event).await;
@@ -213,6 +218,7 @@ pub(crate) async fn handle_mcp_tool_call(
let tool_call_end_event = EventMsg::McpToolCallEnd(McpToolCallEndEvent {
call_id: call_id.clone(),
invocation,
mcp_app_resource_uri: mcp_app_resource_uri.clone(),
duration,
result: result.clone(),
});
@@ -239,6 +245,7 @@ pub(crate) async fn handle_mcp_tool_call(
turn_context.as_ref(),
&call_id,
invocation,
mcp_app_resource_uri.clone(),
message,
/*already_started*/ true,
)
@@ -254,6 +261,7 @@ pub(crate) async fn handle_mcp_tool_call(
turn_context.as_ref(),
&call_id,
invocation,
mcp_app_resource_uri.clone(),
message,
/*already_started*/ true,
)
@@ -268,6 +276,7 @@ pub(crate) async fn handle_mcp_tool_call(
turn_context.as_ref(),
&call_id,
invocation,
mcp_app_resource_uri.clone(),
message,
/*already_started*/ true,
)
@@ -325,6 +334,7 @@ pub(crate) async fn handle_mcp_tool_call(
let tool_call_end_event = EventMsg::McpToolCallEnd(McpToolCallEndEvent {
call_id: call_id.clone(),
invocation,
mcp_app_resource_uri,
duration,
result: result.clone(),
});
@@ -642,11 +652,14 @@ pub(crate) struct McpToolApprovalMetadata {
connector_description: Option<String>,
tool_title: Option<String>,
tool_description: Option<String>,
mcp_app_resource_uri: Option<String>,
codex_apps_meta: Option<serde_json::Map<String, serde_json::Value>>,
openai_file_input_params: Option<Vec<String>>,
}
const MCP_TOOL_CODEX_APPS_META_KEY: &str = "_codex_apps";
const MCP_TOOL_OPENAI_OUTPUT_TEMPLATE_META_KEY: &str = "openai/outputTemplate";
const MCP_TOOL_UI_RESOURCE_URI_META_KEY: &str = "ui/resourceUri";
fn custom_mcp_tool_approval_mode(
turn_context: &TurnContext,
@@ -1100,6 +1113,7 @@ pub(crate) async fn lookup_mcp_tool_metadata(
connector_description,
tool_title: tool_info.tool.title,
tool_description: tool_info.tool.description.map(std::borrow::Cow::into_owned),
mcp_app_resource_uri: get_mcp_app_resource_uri(tool_info.tool.meta.as_deref()),
codex_apps_meta: tool_info
.tool
.meta
@@ -1114,6 +1128,26 @@ pub(crate) async fn lookup_mcp_tool_metadata(
})
}
fn get_mcp_app_resource_uri(
meta: Option<&serde_json::Map<String, serde_json::Value>>,
) -> Option<String> {
meta.and_then(|meta| {
meta.get("ui")
.and_then(serde_json::Value::as_object)
.and_then(|ui| ui.get("resourceUri"))
.and_then(serde_json::Value::as_str)
.or_else(|| {
meta.get(MCP_TOOL_UI_RESOURCE_URI_META_KEY)
.and_then(serde_json::Value::as_str)
})
.or_else(|| {
meta.get(MCP_TOOL_OPENAI_OUTPUT_TEMPLATE_META_KEY)
.and_then(serde_json::Value::as_str)
})
.map(str::to_string)
})
}
async fn lookup_mcp_app_usage_metadata(
sess: &Session,
server: &str,
@@ -1666,6 +1700,7 @@ async fn notify_mcp_tool_call_skip(
turn_context: &TurnContext,
call_id: &str,
invocation: McpInvocation,
mcp_app_resource_uri: Option<String>,
message: String,
already_started: bool,
) -> Result<CallToolResult, String> {
@@ -1673,6 +1708,7 @@ async fn notify_mcp_tool_call_skip(
let tool_call_begin_event = EventMsg::McpToolCallBegin(McpToolCallBeginEvent {
call_id: call_id.to_string(),
invocation: invocation.clone(),
mcp_app_resource_uri: mcp_app_resource_uri.clone(),
});
notify_mcp_tool_call_event(sess, turn_context, tool_call_begin_event).await;
}
@@ -1680,6 +1716,7 @@ async fn notify_mcp_tool_call_skip(
let tool_call_end_event = EventMsg::McpToolCallEnd(McpToolCallEndEvent {
call_id: call_id.to_string(),
invocation,
mcp_app_resource_uri,
duration: Duration::ZERO,
result: Err(message.clone()),
});
+41
View File
@@ -59,6 +59,7 @@ fn approval_metadata(
connector_description: connector_description.map(str::to_string),
tool_title: tool_title.map(str::to_string),
tool_description: tool_description.map(str::to_string),
mcp_app_resource_uri: None,
codex_apps_meta: None,
openai_file_input_params: None,
}
@@ -74,6 +75,35 @@ fn prompt_options(
}
}
#[test]
fn mcp_app_resource_uri_reads_known_tool_meta_keys() {
let nested = serde_json::json!({
"ui": {
"resourceUri": "ui://widget/nested.html",
},
});
assert_eq!(
get_mcp_app_resource_uri(nested.as_object()),
Some("ui://widget/nested.html".to_string())
);
let flat = serde_json::json!({
"ui/resourceUri": "ui://widget/flat.html",
});
assert_eq!(
get_mcp_app_resource_uri(flat.as_object()),
Some("ui://widget/flat.html".to_string())
);
let output_template = serde_json::json!({
"openai/outputTemplate": "ui://widget/output-template.html",
});
assert_eq!(
get_mcp_app_resource_uri(output_template.as_object()),
Some("ui://widget/output-template.html".to_string())
);
}
#[test]
fn approval_required_when_read_only_false_and_destructive() {
let annotations = annotations(Some(false), Some(true), /*open_world*/ None);
@@ -589,6 +619,7 @@ async fn codex_apps_tool_call_request_meta_includes_turn_metadata_and_codex_apps
connector_description: Some("Manage events".to_string()),
tool_title: Some("Create Event".to_string()),
tool_description: Some("Create a calendar event.".to_string()),
mcp_app_resource_uri: None,
codex_apps_meta: Some(
serde_json::json!({
"resource_uri": "connector://calendar/tools/calendar_create_event",
@@ -746,6 +777,7 @@ fn guardian_mcp_review_request_includes_annotations_when_present() {
connector_description: None,
tool_title: None,
tool_description: None,
mcp_app_resource_uri: None,
codex_apps_meta: None,
openai_file_input_params: None,
};
@@ -1272,6 +1304,7 @@ async fn approve_mode_skips_when_annotations_do_not_require_approval() {
connector_description: None,
tool_title: Some("Read Only Tool".to_string()),
tool_description: None,
mcp_app_resource_uri: None,
codex_apps_meta: None,
openai_file_input_params: None,
};
@@ -1340,6 +1373,7 @@ async fn guardian_mode_skips_auto_when_annotations_do_not_require_approval() {
connector_description: None,
tool_title: Some("Read Only Tool".to_string()),
tool_description: None,
mcp_app_resource_uri: None,
codex_apps_meta: None,
openai_file_input_params: None,
};
@@ -1411,6 +1445,7 @@ async fn guardian_mode_mcp_denial_returns_rationale_message() {
connector_description: None,
tool_title: Some("Dangerous Tool".to_string()),
tool_description: Some("Reads calendar data.".to_string()),
mcp_app_resource_uri: None,
codex_apps_meta: None,
openai_file_input_params: None,
};
@@ -1462,6 +1497,7 @@ async fn prompt_mode_waits_for_approval_when_annotations_do_not_require_approval
connector_description: None,
tool_title: Some("Read Only Tool".to_string()),
tool_description: None,
mcp_app_resource_uri: None,
codex_apps_meta: None,
openai_file_input_params: None,
};
@@ -1539,6 +1575,7 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_for_model() {
connector_description: Some("Manage events".to_string()),
tool_title: Some("Dangerous Tool".to_string()),
tool_description: Some("Performs a risky action.".to_string()),
mcp_app_resource_uri: None,
codex_apps_meta: None,
openai_file_input_params: None,
};
@@ -1609,6 +1646,7 @@ async fn custom_approve_mode_blocks_when_arc_returns_interrupt_for_model() {
connector_description: None,
tool_title: Some("Dangerous Tool".to_string()),
tool_description: Some("Performs a risky action.".to_string()),
mcp_app_resource_uri: None,
codex_apps_meta: None,
openai_file_input_params: None,
};
@@ -1679,6 +1717,7 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_without_annotations() {
connector_description: Some("Manage events".to_string()),
tool_title: Some("Dangerous Tool".to_string()),
tool_description: Some("Performs a risky action.".to_string()),
mcp_app_resource_uri: None,
codex_apps_meta: None,
openai_file_input_params: None,
};
@@ -1757,6 +1796,7 @@ async fn full_access_mode_skips_arc_monitor_for_all_approval_modes() {
connector_description: Some("Manage events".to_string()),
tool_title: Some("Dangerous Tool".to_string()),
tool_description: Some("Performs a risky action.".to_string()),
mcp_app_resource_uri: None,
codex_apps_meta: None,
openai_file_input_params: None,
};
@@ -1859,6 +1899,7 @@ async fn approve_mode_routes_arc_ask_user_to_guardian_when_guardian_reviewer_is_
connector_description: Some("Manage events".to_string()),
tool_title: Some("Dangerous Tool".to_string()),
tool_description: Some("Performs a risky action.".to_string()),
mcp_app_resource_uri: None,
codex_apps_meta: None,
openai_file_input_params: None,
};
+2 -2
View File
@@ -468,7 +468,7 @@ mod phase2 {
impl DispatchHarness {
async fn new() -> Self {
let codex_home = tempfile::tempdir().expect("create temp codex home");
let mut config = test_config();
let mut config = test_config().await;
config.codex_home =
codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(codex_home.path())
.expect("codex home is absolute");
@@ -895,7 +895,7 @@ mod phase2 {
#[tokio::test]
async fn dispatch_marks_job_for_retry_when_spawn_agent_fails() {
let codex_home = tempfile::tempdir().expect("create temp codex home");
let mut config = test_config();
let mut config = test_config().await;
config.codex_home =
codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(codex_home.path())
.expect("codex home is absolute");
+1 -1
View File
@@ -106,7 +106,7 @@ mod tests {
async fn build_prompt_input_includes_context_and_user_message() {
let codex_home = tempfile::tempdir().expect("create codex home");
let cwd = tempfile::tempdir().expect("create cwd");
let mut config = test_config();
let mut config = test_config().await;
config.codex_home =
AbsolutePathBuf::from_absolute_path(codex_home.path()).expect("codex home is absolute");
config.cwd = AbsolutePathBuf::try_from(cwd.path().to_path_buf()).expect("absolute cwd");
+23 -19
View File
@@ -62,8 +62,8 @@ pub(crate) async fn build_realtime_startup_context(
let history = sess.clone_history().await;
let current_thread_section = build_current_thread_section(history.raw_items());
let recent_threads = load_recent_threads(sess).await;
let recent_work_section = build_recent_work_section(&cwd, &recent_threads);
let workspace_section = build_workspace_section_with_user_root(&cwd, home_dir());
let recent_work_section = build_recent_work_section(&cwd, &recent_threads).await;
let workspace_section = build_workspace_section_with_user_root(&cwd, home_dir()).await;
if current_thread_section.is_none()
&& recent_work_section.is_none()
@@ -144,16 +144,18 @@ async fn load_recent_threads(sess: &Session) -> Vec<StoredThread> {
}
}
fn build_recent_work_section(cwd: &Path, recent_threads: &[StoredThread]) -> Option<String> {
async fn build_recent_work_section(cwd: &Path, recent_threads: &[StoredThread]) -> Option<String> {
let mut groups: HashMap<PathBuf, Vec<&StoredThread>> = HashMap::new();
for entry in recent_threads {
let group =
resolve_root_git_project_for_trust(&entry.cwd).unwrap_or_else(|| entry.cwd.clone());
let group = resolve_root_git_project_for_trust(&entry.cwd)
.await
.unwrap_or_else(|| entry.cwd.clone());
groups.entry(group).or_default().push(entry);
}
let current_group =
resolve_root_git_project_for_trust(cwd).unwrap_or_else(|| cwd.to_path_buf());
let current_group = resolve_root_git_project_for_trust(cwd)
.await
.unwrap_or_else(|| cwd.to_path_buf());
let mut groups = groups.into_iter().collect::<Vec<_>>();
groups.sort_by(|(left_group, left_entries), (right_group, right_entries)| {
let left_latest = left_entries
@@ -178,14 +180,13 @@ fn build_recent_work_section(cwd: &Path, recent_threads: &[StoredThread]) -> Opt
))
});
let sections = groups
.into_iter()
.take(MAX_RECENT_WORK_GROUPS)
.filter_map(|(group, mut entries)| {
entries.sort_by_key(|entry| Reverse(entry.updated_at));
format_thread_group(&current_group, &group, entries)
})
.collect::<Vec<_>>();
let mut sections = Vec::new();
for (group, mut entries) in groups.into_iter().take(MAX_RECENT_WORK_GROUPS) {
entries.sort_by_key(|entry| Reverse(entry.updated_at));
if let Some(section) = format_thread_group(&current_group, &group, entries).await {
sections.push(section);
}
}
(!sections.is_empty()).then(|| sections.join("\n\n"))
}
@@ -306,11 +307,11 @@ pub(crate) fn truncate_realtime_text_to_token_budget(text: &str, budget_tokens:
}
}
fn build_workspace_section_with_user_root(
async fn build_workspace_section_with_user_root(
cwd: &Path,
user_root: Option<PathBuf>,
) -> Option<String> {
let git_root = resolve_root_git_project_for_trust(cwd);
let git_root = resolve_root_git_project_for_trust(cwd).await;
let cwd_tree = render_tree(cwd);
let git_root_tree = git_root
.as_ref()
@@ -466,13 +467,16 @@ fn format_startup_context_blob(body: &str, budget_tokens: usize) -> String {
}
}
fn format_thread_group(
async fn format_thread_group(
current_group: &Path,
group: &Path,
entries: Vec<&StoredThread>,
) -> Option<String> {
let latest = entries.first()?;
let group_label = if resolve_root_git_project_for_trust(latest.cwd.as_path()).is_some() {
let group_label = if resolve_root_git_project_for_trust(latest.cwd.as_path())
.await
.is_some()
{
format!("### Git repo: {}", group.display())
} else {
format!("### Directory: {}", group.display())
+12 -9
View File
@@ -188,30 +188,31 @@ fn startup_context_blob_is_wrapped_in_tags_and_fits_budget() {
assert!(wrapped.len().div_ceil(4) <= 200);
}
#[test]
fn workspace_section_requires_meaningful_structure() {
#[tokio::test]
async fn workspace_section_requires_meaningful_structure() {
let cwd = TempDir::new().expect("tempdir");
assert_eq!(
build_workspace_section_with_user_root(cwd.path(), /*user_root*/ None),
build_workspace_section_with_user_root(cwd.path(), /*user_root*/ None).await,
None
);
}
#[test]
fn workspace_section_includes_tree_when_entries_exist() {
#[tokio::test]
async fn workspace_section_includes_tree_when_entries_exist() {
let cwd = TempDir::new().expect("tempdir");
fs::create_dir(cwd.path().join("docs")).expect("create docs dir");
fs::write(cwd.path().join("README.md"), "hello").expect("write readme");
let section = build_workspace_section_with_user_root(cwd.path(), /*user_root*/ None)
.await
.expect("workspace section");
assert!(section.contains("Working directory tree:"));
assert!(section.contains("- docs/"));
assert!(section.contains("- README.md"));
}
#[test]
fn workspace_section_includes_user_root_tree_when_distinct() {
#[tokio::test]
async fn workspace_section_includes_user_root_tree_when_distinct() {
let root = TempDir::new().expect("tempdir");
let cwd = root.path().join("cwd");
let git_root = root.path().join("git");
@@ -225,14 +226,15 @@ fn workspace_section_includes_user_root_tree_when_distinct() {
fs::write(user_root.join(".zshrc"), "export TEST=1").expect("write home file");
let section = build_workspace_section_with_user_root(cwd.as_path(), Some(user_root))
.await
.expect("workspace section");
assert!(section.contains("User root tree:"));
assert!(section.contains("- code/"));
assert!(!section.contains("- .zshrc"));
}
#[test]
fn recent_work_section_groups_threads_by_cwd() {
#[tokio::test]
async fn recent_work_section_groups_threads_by_cwd() {
let root = TempDir::new().expect("tempdir");
let repo = root.path().join("repo");
let workspace_a = repo.join("workspace-a");
@@ -268,6 +270,7 @@ fn recent_work_section_groups_threads_by_cwd() {
let repo = fs::canonicalize(repo).expect("canonicalize repo");
let section = build_recent_work_section(current_cwd.as_path(), &recent_threads)
.await
.expect("recent work section");
assert!(section.contains(&format!("### Git repo: {}", repo.display())));
assert!(section.contains("Recent sessions: 2"));
+5 -5
View File
@@ -237,7 +237,7 @@ async fn ignores_session_prefix_messages_when_truncating() {
#[tokio::test]
async fn shutdown_all_threads_bounded_submits_shutdown_to_every_thread() {
let temp_dir = tempdir().expect("tempdir");
let mut config = test_config();
let mut config = test_config().await;
config.codex_home = temp_dir.path().join("codex-home").abs();
config.cwd = config.codex_home.abs();
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
@@ -279,7 +279,7 @@ async fn new_uses_configured_openai_provider_for_model_refresh() {
let models_mock = mount_models_once(&server, ModelsResponse { models: vec![] }).await;
let temp_dir = tempdir().expect("tempdir");
let mut config = test_config();
let mut config = test_config().await;
config.codex_home = temp_dir.path().join("codex-home").abs();
config.cwd = config.codex_home.abs();
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
@@ -422,7 +422,7 @@ fn mixed_response_and_legacy_user_event_history_is_mid_turn() {
#[tokio::test]
async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_history() {
let temp_dir = tempdir().expect("tempdir");
let mut config = test_config();
let mut config = test_config().await;
config.codex_home = temp_dir.path().join("codex-home").abs();
config.cwd = config.codex_home.abs();
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
@@ -525,7 +525,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor
#[tokio::test]
async fn interrupted_fork_snapshot_preserves_explicit_turn_id() {
let temp_dir = tempdir().expect("tempdir");
let mut config = test_config();
let mut config = test_config().await;
config.codex_home = temp_dir.path().join("codex-home").abs();
config.cwd = config.codex_home.abs();
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
@@ -618,7 +618,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() {
#[tokio::test]
async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_source() {
let temp_dir = tempdir().expect("tempdir");
let mut config = test_config();
let mut config = test_config().await;
config.codex_home = temp_dir.path().join("codex-home").abs();
config.cwd = config.codex_home.abs();
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
@@ -562,6 +562,7 @@ async fn emit_tool_call_begin(
EventMsg::McpToolCallBegin(McpToolCallBeginEvent {
call_id: call_id.to_string(),
invocation,
mcp_app_resource_uri: None,
}),
)
.await;
@@ -581,6 +582,7 @@ async fn emit_tool_call_end(
EventMsg::McpToolCallEnd(McpToolCallEndEvent {
call_id: call_id.to_string(),
invocation,
mcp_app_resource_uri: None,
duration,
result,
}),
+106 -93
View File
@@ -107,8 +107,8 @@ fn discoverable_connector(id: &str, name: &str, description: &str) -> Discoverab
}))
}
fn search_capable_model_info() -> ModelInfo {
let config = test_config();
async fn search_capable_model_info() -> ModelInfo {
let config = test_config().await;
let mut model_info = construct_model_info_offline("gpt-5-codex", &config);
model_info.supports_search_tool = true;
model_info
@@ -216,8 +216,8 @@ fn find_namespace_function_tool<'a>(
.unwrap_or_else(|| panic!("expected tool {expected_namespace}{expected_name} in namespace"))
}
fn multi_agent_v2_tools_config() -> ToolsConfig {
let config = test_config();
async fn multi_agent_v2_tools_config() -> ToolsConfig {
let config = test_config().await;
let model_info = construct_model_info_offline("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.enable(Feature::Collab);
@@ -250,8 +250,8 @@ fn multi_agent_v2_spawn_agent_description(tools_config: &ToolsConfig) -> String
description.clone()
}
fn model_info_from_models_json(slug: &str) -> ModelInfo {
let config = test_config();
async fn model_info_from_models_json(slug: &str) -> ModelInfo {
let config = test_config().await;
let response = bundled_models_response()
.unwrap_or_else(|err| panic!("bundled models.json should parse: {err}"));
let model = response
@@ -295,9 +295,9 @@ fn build_specs_with_unavailable_tools(
)
}
#[test]
fn model_provided_unified_exec_is_blocked_for_windows_sandboxed_policies() {
let mut model_info = model_info_from_models_json("gpt-5-codex");
#[tokio::test]
async fn model_provided_unified_exec_is_blocked_for_windows_sandboxed_policies() {
let mut model_info = model_info_from_models_json("gpt-5-codex").await;
model_info.shell_type = ConfigShellToolType::UnifiedExec;
let features = Features::with_defaults();
let available_models = Vec::new();
@@ -320,9 +320,9 @@ fn model_provided_unified_exec_is_blocked_for_windows_sandboxed_policies() {
assert_eq!(config.shell_type, expected_shell_type);
}
#[test]
fn get_memory_requires_feature_flag() {
let config = test_config();
#[tokio::test]
async fn get_memory_requires_feature_flag() {
let config = test_config().await;
let model_info = construct_model_info_offline("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.disable(Feature::MemoryTool);
@@ -350,14 +350,14 @@ fn get_memory_requires_feature_flag() {
);
}
fn assert_model_tools(
async fn assert_model_tools(
model_slug: &str,
features: &Features,
web_search_mode: Option<WebSearchMode>,
expected_tools: &[&str],
) {
let _config = test_config();
let model_info = model_info_from_models_json(model_slug);
let _config = test_config().await;
let model_info = model_info_from_models_json(model_slug).await;
let available_models = Vec::new();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
@@ -388,7 +388,7 @@ fn assert_model_tools(
assert_eq!(&tool_names, &expected_tools,);
}
fn assert_default_model_tools(
async fn assert_default_model_tools(
model_slug: &str,
features: &Features,
web_search_mode: Option<WebSearchMode>,
@@ -401,11 +401,11 @@ fn assert_default_model_tools(
vec![shell_tool]
};
expected.extend(expected_tail);
assert_model_tools(model_slug, features, web_search_mode, &expected);
assert_model_tools(model_slug, features, web_search_mode, &expected).await;
}
#[test]
fn test_build_specs_gpt5_codex_default() {
#[tokio::test]
async fn test_build_specs_gpt5_codex_default() {
let features = Features::with_defaults();
assert_default_model_tools(
"gpt-5-codex",
@@ -424,11 +424,12 @@ fn test_build_specs_gpt5_codex_default() {
"wait_agent",
"close_agent",
],
);
)
.await;
}
#[test]
fn test_build_specs_gpt51_codex_default() {
#[tokio::test]
async fn test_build_specs_gpt51_codex_default() {
let features = Features::with_defaults();
assert_default_model_tools(
"gpt-5.1-codex",
@@ -447,11 +448,12 @@ fn test_build_specs_gpt51_codex_default() {
"wait_agent",
"close_agent",
],
);
)
.await;
}
#[test]
fn test_build_specs_gpt5_codex_unified_exec_web_search() {
#[tokio::test]
async fn test_build_specs_gpt5_codex_unified_exec_web_search() {
let mut features = Features::with_defaults();
features.enable(Feature::UnifiedExec);
assert_model_tools(
@@ -472,11 +474,12 @@ fn test_build_specs_gpt5_codex_unified_exec_web_search() {
"wait_agent",
"close_agent",
],
);
)
.await;
}
#[test]
fn test_build_specs_gpt51_codex_unified_exec_web_search() {
#[tokio::test]
async fn test_build_specs_gpt51_codex_unified_exec_web_search() {
let mut features = Features::with_defaults();
features.enable(Feature::UnifiedExec);
assert_model_tools(
@@ -497,11 +500,12 @@ fn test_build_specs_gpt51_codex_unified_exec_web_search() {
"wait_agent",
"close_agent",
],
);
)
.await;
}
#[test]
fn test_gpt_5_1_codex_max_defaults() {
#[tokio::test]
async fn test_gpt_5_1_codex_max_defaults() {
let features = Features::with_defaults();
assert_default_model_tools(
"gpt-5.1-codex-max",
@@ -520,11 +524,12 @@ fn test_gpt_5_1_codex_max_defaults() {
"wait_agent",
"close_agent",
],
);
)
.await;
}
#[test]
fn test_codex_5_1_mini_defaults() {
#[tokio::test]
async fn test_codex_5_1_mini_defaults() {
let features = Features::with_defaults();
assert_default_model_tools(
"gpt-5.1-codex-mini",
@@ -543,11 +548,12 @@ fn test_codex_5_1_mini_defaults() {
"wait_agent",
"close_agent",
],
);
)
.await;
}
#[test]
fn test_gpt_5_defaults() {
#[tokio::test]
async fn test_gpt_5_defaults() {
let features = Features::with_defaults();
assert_default_model_tools(
"gpt-5",
@@ -565,11 +571,12 @@ fn test_gpt_5_defaults() {
"wait_agent",
"close_agent",
],
);
)
.await;
}
#[test]
fn test_gpt_5_1_defaults() {
#[tokio::test]
async fn test_gpt_5_1_defaults() {
let features = Features::with_defaults();
assert_default_model_tools(
"gpt-5.1",
@@ -588,11 +595,12 @@ fn test_gpt_5_1_defaults() {
"wait_agent",
"close_agent",
],
);
)
.await;
}
#[test]
fn test_gpt_5_1_codex_max_unified_exec_web_search() {
#[tokio::test]
async fn test_gpt_5_1_codex_max_unified_exec_web_search() {
let mut features = Features::with_defaults();
features.enable(Feature::UnifiedExec);
assert_model_tools(
@@ -613,12 +621,13 @@ fn test_gpt_5_1_codex_max_unified_exec_web_search() {
"wait_agent",
"close_agent",
],
);
)
.await;
}
#[test]
fn test_build_specs_default_shell_present() {
let config = test_config();
#[tokio::test]
async fn test_build_specs_default_shell_present() {
let config = test_config().await;
let model_info = construct_model_info_offline("o3", &config);
let mut features = Features::with_defaults();
features.enable(Feature::UnifiedExec);
@@ -649,9 +658,9 @@ fn test_build_specs_default_shell_present() {
assert_contains_tool_names(&tools, &subset);
}
#[test]
fn shell_zsh_fork_prefers_shell_command_over_unified_exec() {
let config = test_config();
#[tokio::test]
async fn shell_zsh_fork_prefers_shell_command_over_unified_exec() {
let config = test_config().await;
let model_info = construct_model_info_offline("o3", &config);
let mut features = Features::with_defaults();
features.enable(Feature::UnifiedExec);
@@ -713,9 +722,10 @@ fn shell_zsh_fork_prefers_shell_command_over_unified_exec() {
);
}
#[test]
fn spawn_agent_description_omits_usage_hint_when_disabled() {
#[tokio::test]
async fn spawn_agent_description_omits_usage_hint_when_disabled() {
let tools_config = multi_agent_v2_tools_config()
.await
.with_spawn_agent_usage_hint(/*spawn_agent_usage_hint*/ false);
let description = multi_agent_v2_spawn_agent_description(&tools_config);
@@ -734,11 +744,13 @@ fn spawn_agent_description_omits_usage_hint_when_disabled() {
);
}
#[test]
fn spawn_agent_description_uses_configured_usage_hint_text() {
let tools_config = multi_agent_v2_tools_config().with_spawn_agent_usage_hint_text(Some(
/*spawn_agent_usage_hint_text*/ "Custom delegation guidance only.".to_string(),
));
#[tokio::test]
async fn spawn_agent_description_uses_configured_usage_hint_text() {
let tools_config = multi_agent_v2_tools_config()
.await
.with_spawn_agent_usage_hint_text(Some(
/*spawn_agent_usage_hint_text*/ "Custom delegation guidance only.".to_string(),
));
let description = multi_agent_v2_spawn_agent_description(&tools_config);
assert_regex_match(
@@ -757,9 +769,9 @@ fn spawn_agent_description_uses_configured_usage_hint_text() {
);
}
#[test]
fn tool_suggest_requires_apps_and_plugins_features() {
let model_info = search_capable_model_info();
#[tokio::test]
async fn tool_suggest_requires_apps_and_plugins_features() {
let model_info = search_capable_model_info().await;
let discoverable_tools = Some(vec![discoverable_connector(
"connector_2128aebfecb84f64a069897515042a44",
"Google Calendar",
@@ -804,9 +816,9 @@ fn tool_suggest_requires_apps_and_plugins_features() {
}
}
#[test]
fn search_tool_description_handles_no_enabled_mcp_tools() {
let model_info = search_capable_model_info();
#[tokio::test]
async fn search_tool_description_handles_no_enabled_mcp_tools() {
let model_info = search_capable_model_info().await;
let mut features = Features::with_defaults();
features.enable(Feature::Apps);
features.enable(Feature::ToolSearch);
@@ -838,9 +850,9 @@ fn search_tool_description_handles_no_enabled_mcp_tools() {
assert!(!description.contains("{{source_descriptions}}"));
}
#[test]
fn search_tool_description_falls_back_to_connector_name_without_description() {
let model_info = search_capable_model_info();
#[tokio::test]
async fn search_tool_description_falls_back_to_connector_name_without_description() {
let model_info = search_capable_model_info().await;
let mut features = Features::with_defaults();
features.enable(Feature::Apps);
features.enable(Feature::ToolSearch);
@@ -889,9 +901,9 @@ fn search_tool_description_falls_back_to_connector_name_without_description() {
assert!(!description.contains("- Calendar:"));
}
#[test]
fn search_tool_registers_namespaced_mcp_tool_aliases() {
let model_info = search_capable_model_info();
#[tokio::test]
async fn search_tool_registers_namespaced_mcp_tool_aliases() {
let model_info = search_capable_model_info().await;
let mut features = Features::with_defaults();
features.enable(Feature::Apps);
features.enable(Feature::ToolSearch);
@@ -974,9 +986,9 @@ fn search_tool_registers_namespaced_mcp_tool_aliases() {
assert!(registry.has_handler(&mcp_alias));
}
#[test]
fn direct_mcp_tools_register_namespaced_handlers() {
let config = test_config();
#[tokio::test]
async fn direct_mcp_tools_register_namespaced_handlers() {
let config = test_config().await;
let model_info = construct_model_info_offline("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.enable(Feature::UnifiedExec);
@@ -1011,9 +1023,9 @@ fn direct_mcp_tools_register_namespaced_handlers() {
assert!(!registry.has_handler(&ToolName::plain("mcp__test_server__echo")));
}
#[test]
fn unavailable_mcp_tools_are_exposed_as_dummy_function_tools() {
let config = test_config();
#[tokio::test]
async fn unavailable_mcp_tools_are_exposed_as_dummy_function_tools() {
let config = test_config().await;
let model_info = construct_model_info_offline("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.enable(Feature::UnifiedExec);
@@ -1060,9 +1072,9 @@ fn unavailable_mcp_tools_are_exposed_as_dummy_function_tools() {
assert!(!registry.has_handler(&ToolName::plain("mcp__codex_apps__calendar_create_event")));
}
#[test]
fn test_mcp_tool_property_missing_type_defaults_to_string() {
let config = test_config();
#[tokio::test]
async fn test_mcp_tool_property_missing_type_defaults_to_string() {
let config = test_config().await;
let model_info = construct_model_info_offline("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.enable(Feature::UnifiedExec);
@@ -1123,9 +1135,9 @@ fn test_mcp_tool_property_missing_type_defaults_to_string() {
);
}
#[test]
fn test_mcp_tool_preserves_integer_schema() {
let config = test_config();
#[tokio::test]
async fn test_mcp_tool_preserves_integer_schema() {
let config = test_config().await;
let model_info = construct_model_info_offline("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.enable(Feature::UnifiedExec);
@@ -1184,9 +1196,9 @@ fn test_mcp_tool_preserves_integer_schema() {
);
}
#[test]
fn test_mcp_tool_array_without_items_gets_default_string_items() {
let config = test_config();
#[tokio::test]
async fn test_mcp_tool_array_without_items_gets_default_string_items() {
let config = test_config().await;
let model_info = construct_model_info_offline("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.enable(Feature::UnifiedExec);
@@ -1249,9 +1261,9 @@ fn test_mcp_tool_array_without_items_gets_default_string_items() {
);
}
#[test]
fn test_mcp_tool_anyof_defaults_to_string() {
let config = test_config();
#[tokio::test]
async fn test_mcp_tool_anyof_defaults_to_string() {
let config = test_config().await;
let model_info = construct_model_info_offline("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.enable(Feature::UnifiedExec);
@@ -1318,9 +1330,9 @@ fn test_mcp_tool_anyof_defaults_to_string() {
);
}
#[test]
fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() {
let config = test_config();
#[tokio::test]
async fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() {
let config = test_config().await;
let model_info = construct_model_info_offline("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.enable(Feature::UnifiedExec);
@@ -1433,8 +1445,8 @@ fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() {
);
}
#[test]
fn code_mode_only_restricts_model_tools_to_exec_tools() {
#[tokio::test]
async fn code_mode_only_restricts_model_tools_to_exec_tools() {
let mut features = Features::with_defaults();
features.enable(Feature::CodeMode);
features.enable(Feature::CodeModeOnly);
@@ -1444,5 +1456,6 @@ fn code_mode_only_restricts_model_tools_to_exec_tools() {
&features,
Some(WebSearchMode::Live),
&["exec", "wait"],
);
)
.await;
}
@@ -21,6 +21,8 @@ const SERVER_VERSION: &str = "1.0.0";
const SEARCHABLE_TOOL_COUNT: usize = 100;
pub const CALENDAR_CREATE_EVENT_RESOURCE_URI: &str =
"connector://calendar/tools/calendar_create_event";
pub const CALENDAR_CREATE_EVENT_MCP_APP_RESOURCE_URI: &str =
"ui://widget/calendar-create-event.html";
const CALENDAR_LIST_EVENTS_RESOURCE_URI: &str = "connector://calendar/tools/calendar_list_events";
pub const DOCUMENT_EXTRACT_TEXT_RESOURCE_URI: &str =
"connector://calendar/tools/calendar_extract_text";
@@ -206,6 +208,7 @@ impl Respond for CodexAppsJsonRpcResponder {
"connector_id": CONNECTOR_ID,
"connector_name": self.connector_name.clone(),
"connector_description": self.connector_description.clone(),
"openai/outputTemplate": CALENDAR_CREATE_EVENT_MCP_APP_RESOURCE_URI,
"_codex_apps": {
"resource_uri": CALENDAR_CREATE_EVENT_RESOURCE_URI,
"contains_mcp_source": true,
+1
View File
@@ -736,6 +736,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> {
tool: "image".to_string(),
arguments: Some(json!({})),
},
mcp_app_resource_uri: None,
},
);
+28 -6
View File
@@ -15,6 +15,7 @@ use codex_protocol::protocol::Op;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::user_input::UserInput;
use core_test_support::apps_test_server::AppsTestServer;
use core_test_support::apps_test_server::CALENDAR_CREATE_EVENT_MCP_APP_RESOURCE_URI;
use core_test_support::apps_test_server::CALENDAR_CREATE_EVENT_RESOURCE_URI;
use core_test_support::responses::ResponsesRequest;
use core_test_support::responses::ev_assistant_message;
@@ -93,7 +94,7 @@ fn tool_search_output_tools(request: &ResponsesRequest, call_id: &str) -> Vec<Va
.unwrap_or_default()
}
fn configure_apps_without_tool_search(config: &mut Config, apps_base_url: &str) {
fn configure_search_capable_apps(config: &mut Config, apps_base_url: &str) {
config
.features
.enable(Feature::Apps)
@@ -112,14 +113,18 @@ fn configure_apps_without_tool_search(config: &mut Config, apps_base_url: &str)
config.model_catalog = Some(model_catalog);
}
fn configure_apps(config: &mut Config, apps_base_url: &str) {
configure_apps_without_tool_search(config, apps_base_url);
fn configure_apps_without_tool_search(config: &mut Config, apps_base_url: &str) {
configure_search_capable_apps(config, apps_base_url);
config
.features
.enable(Feature::ToolSearch)
.disable(Feature::ToolSearch)
.expect("test config should allow feature update");
}
fn configure_apps(config: &mut Config, apps_base_url: &str) {
configure_search_capable_apps(config, apps_base_url);
}
fn configured_builder(apps_base_url: String) -> TestCodexBuilder {
test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
@@ -127,7 +132,7 @@ fn configured_builder(apps_base_url: String) -> TestCodexBuilder {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn search_tool_flag_adds_tool_search() -> Result<()> {
async fn search_tool_enabled_by_default_adds_tool_search() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
@@ -185,7 +190,7 @@ async fn search_tool_flag_adds_tool_search() -> Result<()> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn tool_search_disabled_by_default_exposes_apps_tools_directly() -> Result<()> {
async fn tool_search_disabled_exposes_apps_tools_directly() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
@@ -453,6 +458,19 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() -
})
.await?;
let EventMsg::McpToolCallBegin(begin) = wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::McpToolCallBegin(_))
})
.await
else {
unreachable!("event guard guarantees McpToolCallBegin");
};
assert_eq!(begin.call_id, "calendar-call-1");
assert_eq!(
begin.mcp_app_resource_uri.as_deref(),
Some(CALENDAR_CREATE_EVENT_MCP_APP_RESOURCE_URI)
);
let EventMsg::McpToolCallEnd(end) = wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::McpToolCallEnd(_))
})
@@ -461,6 +479,10 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() -
unreachable!("event guard guarantees McpToolCallEnd");
};
assert_eq!(end.call_id, "calendar-call-1");
assert_eq!(
end.mcp_app_resource_uri.as_deref(),
Some(CALENDAR_CREATE_EVENT_MCP_APP_RESOURCE_URI)
);
assert_eq!(
end.invocation,
McpInvocation {
@@ -464,6 +464,7 @@ fn mcp_tool_call_begin_and_end_emit_item_events() {
tool: "tool_x".to_string(),
status: ApiMcpToolCallStatus::InProgress,
arguments: json!({ "key": "value" }),
mcp_app_resource_uri: None,
result: None,
error: None,
duration_ms: None,
@@ -479,11 +480,12 @@ fn mcp_tool_call_begin_and_end_emit_item_events() {
tool: "tool_x".to_string(),
status: ApiMcpToolCallStatus::Completed,
arguments: json!({ "key": "value" }),
result: Some(McpToolCallResult {
mcp_app_resource_uri: None,
result: Some(Box::new(McpToolCallResult {
content: Vec::new(),
structured_content: None,
meta: None,
}),
})),
error: None,
duration_ms: Some(1_000),
},
@@ -547,6 +549,7 @@ fn mcp_tool_call_failure_sets_failed_status() {
tool: "tool_y".to_string(),
status: ApiMcpToolCallStatus::Failed,
arguments: json!({ "param": 42 }),
mcp_app_resource_uri: None,
result: None,
error: Some(McpToolCallError {
message: "tool exploded".to_string(),
@@ -593,6 +596,7 @@ fn mcp_tool_call_defaults_arguments_and_preserves_structured_content() {
tool: "tool_z".to_string(),
status: ApiMcpToolCallStatus::InProgress,
arguments: serde_json::Value::Null,
mcp_app_resource_uri: None,
result: None,
error: None,
duration_ms: None,
@@ -608,14 +612,15 @@ fn mcp_tool_call_defaults_arguments_and_preserves_structured_content() {
tool: "tool_z".to_string(),
status: ApiMcpToolCallStatus::Completed,
arguments: serde_json::Value::Null,
result: Some(McpToolCallResult {
mcp_app_resource_uri: None,
result: Some(Box::new(McpToolCallResult {
content: vec![json!({
"type": "text",
"text": "done",
})],
structured_content: Some(json!({ "status": "ok" })),
meta: None,
}),
})),
error: None,
duration_ms: Some(10),
},
+2 -2
View File
@@ -788,8 +788,8 @@ pub const FEATURES: &[FeatureSpec] = &[
FeatureSpec {
id: Feature::ToolSearch,
key: "tool_search",
stage: Stage::UnderDevelopment,
default_enabled: false,
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::UnavailableDummyTools,
+3 -3
View File
@@ -128,9 +128,9 @@ fn tool_suggest_is_stable_and_enabled_by_default() {
}
#[test]
fn tool_search_is_under_development_and_disabled_by_default() {
assert_eq!(Feature::ToolSearch.stage(), Stage::UnderDevelopment);
assert_eq!(Feature::ToolSearch.default_enabled(), false);
fn tool_search_is_stable_and_enabled_by_default() {
assert_eq!(Feature::ToolSearch.stage(), Stage::Stable);
assert_eq!(Feature::ToolSearch.default_enabled(), true);
}
#[test]
+1 -1
View File
@@ -618,7 +618,7 @@ async fn diff_against_sha(cwd: &Path, sha: &GitSha) -> Option<String> {
/// `[get_git_repo_root]`, but resolves to the root of the main
/// repository. Handles worktrees via filesystem inspection without invoking
/// the `git` executable.
pub fn resolve_root_git_project_for_trust(cwd: &Path) -> Option<PathBuf> {
pub async fn resolve_root_git_project_for_trust(cwd: &Path) -> Option<PathBuf> {
let base = if cwd.is_dir() { cwd } else { cwd.parent()? };
let (repo_root, dot_git) = find_ancestor_git_entry(base)?;
if dot_git.is_dir() {
+6
View File
@@ -2362,6 +2362,9 @@ pub struct McpToolCallBeginEvent {
/// Identifier so this can be paired with the McpToolCallEnd event.
pub call_id: String,
pub invocation: McpInvocation,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub mcp_app_resource_uri: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS, PartialEq)]
@@ -2369,6 +2372,9 @@ pub struct McpToolCallEndEvent {
/// Identifier for the corresponding McpToolCallBegin that finished.
pub call_id: String,
pub invocation: McpInvocation,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub mcp_app_resource_uri: Option<String>,
#[ts(type = "string")]
pub duration: Duration,
/// Result of the tool call. Note this could be an error.
@@ -1334,7 +1334,7 @@ fn search_tool_description_lists_each_mcp_source_once() {
}
#[test]
fn search_tool_requires_model_capability_and_feature_flag() {
fn search_tool_requires_model_capability_and_enabled_feature() {
let model_info = search_capable_model_info();
let deferred_mcp_tools = Some(vec![deferred_mcp_tool(
"_create_event",
@@ -1367,10 +1367,12 @@ fn search_tool_requires_model_capability_and_feature_flag() {
);
assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME);
let mut features_without_tool_search = Features::with_defaults();
features_without_tool_search.disable(Feature::ToolSearch);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
available_models: &available_models,
features: &features,
features: &features_without_tool_search,
image_generation_tool_auth_allowed: true,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
@@ -1385,8 +1387,6 @@ fn search_tool_requires_model_capability_and_feature_flag() {
);
assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME);
let mut features = Features::with_defaults();
features.enable(Feature::ToolSearch);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
available_models: &available_models,
+1
View File
@@ -1599,6 +1599,7 @@ impl App {
self.config.cwd.to_path_buf(),
version,
)
.with_yolo_mode(history_cell::is_yolo_mode(&self.config))
.display_lines(width)
}
+25 -14
View File
@@ -4759,6 +4759,7 @@ impl ChatWidget {
invocation,
duration,
result,
..
} = ev;
let extra_cell = match self
@@ -5943,6 +5944,7 @@ impl ChatWidget {
server,
tool,
arguments,
mcp_app_resource_uri,
result,
error,
duration_ms,
@@ -5955,15 +5957,19 @@ impl ChatWidget {
tool,
arguments: Some(arguments),
},
mcp_app_resource_uri,
duration: Duration::from_millis(duration_ms.unwrap_or_default().max(0) as u64),
result: match (result, error) {
(_, Some(error)) => Err(error.message),
(Some(result), None) => Ok(codex_protocol::mcp::CallToolResult {
content: result.content,
structured_content: result.structured_content,
is_error: Some(false),
meta: None,
}),
(Some(result), None) => {
let result = *result;
Ok(codex_protocol::mcp::CallToolResult {
content: result.content,
structured_content: result.structured_content,
is_error: Some(false),
meta: None,
})
}
(None, None) => Err("MCP tool call completed without a result".to_string()),
},
});
@@ -6453,6 +6459,7 @@ impl ChatWidget {
server,
tool,
arguments,
mcp_app_resource_uri,
..
} => {
self.on_mcp_tool_call_begin(McpToolCallBeginEvent {
@@ -6462,6 +6469,7 @@ impl ChatWidget {
tool,
arguments: Some(arguments),
},
mcp_app_resource_uri,
});
}
ThreadItem::WebSearch { id, .. } => {
@@ -9722,14 +9730,17 @@ impl ChatWidget {
/// Build a placeholder header cell while the session is configuring.
fn placeholder_session_header_cell(config: &Config) -> Box<dyn HistoryCell> {
let placeholder_style = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC);
Box::new(history_cell::SessionHeaderHistoryCell::new_with_style(
DEFAULT_MODEL_DISPLAY_NAME.to_string(),
placeholder_style,
/*reasoning_effort*/ None,
/*show_fast_status*/ false,
config.cwd.to_path_buf(),
CODEX_CLI_VERSION,
))
Box::new(
history_cell::SessionHeaderHistoryCell::new_with_style(
DEFAULT_MODEL_DISPLAY_NAME.to_string(),
placeholder_style,
/*reasoning_effort*/ None,
/*show_fast_status*/ false,
config.cwd.to_path_buf(),
CODEX_CLI_VERSION,
)
.with_yolo_mode(history_cell::is_yolo_mode(config)),
)
}
/// Merge the real session info cell with any placeholder header to avoid double boxes.
@@ -10,6 +10,7 @@ pub(super) async fn test_config() -> Config {
.keep();
let mut config =
Config::load_default_with_cli_overrides_for_codex_home(codex_home.clone(), Vec::new())
.await
.expect("config");
config.codex_home = codex_home.abs();
config.sqlite_home = codex_home.clone();
+58 -3
View File
@@ -61,9 +61,11 @@ use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::plan_tool::PlanItemArg;
use codex_protocol::plan_tool::StepStatus;
use codex_protocol::plan_tool::UpdatePlanArgs;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::FileChange;
use codex_protocol::protocol::McpAuthStatus;
use codex_protocol::protocol::McpInvocation;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionConfiguredEvent;
use codex_protocol::request_user_input::RequestUserInputAnswer;
use codex_protocol::request_user_input::RequestUserInputQuestion;
@@ -1186,6 +1188,8 @@ pub(crate) fn new_session_info(
let SessionConfiguredEvent {
model,
reasoning_effort,
approval_policy,
sandbox_policy,
..
} = event;
// Header box rendered as history (so it appears at the very top)
@@ -1195,7 +1199,8 @@ pub(crate) fn new_session_info(
show_fast_status,
config.cwd.to_path_buf(),
CODEX_CLI_VERSION,
);
)
.with_yolo_mode(has_yolo_permissions(approval_policy, &sandbox_policy));
let mut parts: Vec<Box<dyn HistoryCell>> = vec![Box::new(header)];
if is_first_event {
@@ -1259,6 +1264,17 @@ pub(crate) fn new_session_info(
SessionInfoCell(CompositeHistoryCell { parts })
}
pub(crate) fn is_yolo_mode(config: &Config) -> bool {
has_yolo_permissions(
config.permissions.approval_policy.value(),
config.permissions.sandbox_policy.get(),
)
}
fn has_yolo_permissions(approval_policy: AskForApproval, sandbox_policy: &SandboxPolicy) -> bool {
approval_policy == AskForApproval::Never && *sandbox_policy == SandboxPolicy::DangerFullAccess
}
pub(crate) fn new_user_prompt(
message: String,
text_elements: Vec<TextElement>,
@@ -1281,6 +1297,7 @@ pub(crate) struct SessionHeaderHistoryCell {
reasoning_effort: Option<ReasoningEffortConfig>,
show_fast_status: bool,
directory: PathBuf,
yolo_mode: bool,
}
impl SessionHeaderHistoryCell {
@@ -1316,9 +1333,15 @@ impl SessionHeaderHistoryCell {
reasoning_effort,
show_fast_status,
directory,
yolo_mode: false,
}
}
pub(crate) fn with_yolo_mode(mut self, yolo_mode: bool) -> Self {
self.yolo_mode = yolo_mode;
self
}
fn format_directory(&self, max_width: Option<usize>) -> String {
Self::format_directory_inner(&self.directory, max_width)
}
@@ -1377,7 +1400,12 @@ impl HistoryCell for SessionHeaderHistoryCell {
const CHANGE_MODEL_HINT_COMMAND: &str = "/model";
const CHANGE_MODEL_HINT_EXPLANATION: &str = " to change";
const DIR_LABEL: &str = "directory:";
let label_width = DIR_LABEL.len();
const PERMISSIONS_LABEL: &str = "permissions:";
let label_width = if self.yolo_mode {
DIR_LABEL.len().max(PERMISSIONS_LABEL.len())
} else {
DIR_LABEL.len()
};
let model_label = format!(
"{model_label:<label_width$}",
@@ -1411,13 +1439,21 @@ impl HistoryCell for SessionHeaderHistoryCell {
let dir = self.format_directory(Some(dir_max_width));
let dir_spans = vec![Span::from(dir_prefix).dim(), Span::from(dir)];
let lines = vec![
let mut lines = vec![
make_row(title_spans),
make_row(Vec::new()),
make_row(model_spans),
make_row(dir_spans),
];
if self.yolo_mode {
let permissions_label = format!("{PERMISSIONS_LABEL:<label_width$}");
lines.push(make_row(vec![
Span::from(format!("{permissions_label} ")).dim(),
"YOLO mode".magenta().bold(),
]));
}
with_border(lines)
}
}
@@ -3956,6 +3992,25 @@ mod tests {
assert!(!model_line.contains("fast"));
}
#[test]
#[cfg_attr(
target_os = "windows",
ignore = "snapshot path rendering differs on Windows"
)]
fn session_header_indicates_yolo_mode() {
let cell = SessionHeaderHistoryCell::new(
"gpt-5".to_string(),
/*reasoning_effort*/ None,
/*show_fast_status*/ false,
test_path_buf("/tmp/project").abs().to_path_buf(),
"test",
)
.with_yolo_mode(/*yolo_mode*/ true);
let rendered = render_lines(&cell.display_lines(/*width*/ 80)).join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn session_header_directory_center_truncates() {
let mut dir = home_dir().expect("home directory");
@@ -4,6 +4,7 @@ use crate::legacy_core::windows_sandbox::WindowsSandboxLevelExt;
use codex_app_server_client::AppServerEvent;
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_protocol::ServerNotification;
use codex_git_utils::resolve_root_git_project_for_trust;
#[cfg(target_os = "windows")]
use codex_protocol::config_types::WindowsSandboxLevel;
use crossterm::event::KeyCode;
@@ -77,7 +78,7 @@ pub(crate) struct OnboardingResult {
}
impl OnboardingScreen {
pub(crate) fn new(tui: &mut Tui, args: OnboardingScreenArgs) -> Self {
pub(crate) async fn new(tui: &mut Tui, args: OnboardingScreenArgs) -> Self {
let OnboardingScreenArgs {
show_trust_screen,
show_login_screen,
@@ -122,8 +123,12 @@ impl OnboardingScreen {
let show_windows_create_sandbox_hint = false;
let highlighted = TrustDirectorySelection::Trust;
if show_trust_screen {
let trust_target = resolve_root_git_project_for_trust(&cwd)
.await
.unwrap_or_else(|| cwd.clone());
steps.push(Step::TrustDirectory(TrustDirectoryWidget {
cwd,
trust_target,
codex_home,
show_windows_create_sandbox_hint,
should_quit: false,
@@ -452,7 +457,7 @@ pub(crate) async fn run_onboarding_app(
) -> Result<OnboardingResult> {
use tokio_stream::StreamExt;
let mut onboarding_screen = OnboardingScreen::new(tui, args);
let mut onboarding_screen = OnboardingScreen::new(tui, args).await;
// One-time guard to fully clear the screen after ChatGPT login success message is shown
let mut did_full_clear_after_success = false;
@@ -1,7 +1,6 @@
use std::path::PathBuf;
use crate::legacy_core::config::set_project_trust_level;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_protocol::config_types::TrustLevel;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@@ -27,6 +26,7 @@ use super::onboarding_screen::StepState;
pub(crate) struct TrustDirectoryWidget {
pub codex_home: PathBuf,
pub cwd: PathBuf,
pub trust_target: PathBuf,
pub show_windows_create_sandbox_hint: bool,
pub should_quit: bool,
pub selection: Option<TrustDirectorySelection>,
@@ -142,8 +142,7 @@ impl StepStateProvider for TrustDirectoryWidget {
impl TrustDirectoryWidget {
fn handle_trust(&mut self) {
let target =
resolve_root_git_project_for_trust(&self.cwd).unwrap_or_else(|| self.cwd.clone());
let target = self.trust_target.clone();
if let Err(e) = set_project_trust_level(&self.codex_home, &target, TrustLevel::Trusted) {
tracing::error!("Failed to set project trusted: {e:?}");
self.error = Some(format!("Failed to set trust for {}: {e}", target.display()));
@@ -182,6 +181,7 @@ mod tests {
let mut widget = TrustDirectoryWidget {
codex_home: codex_home.path().to_path_buf(),
cwd: PathBuf::from("."),
trust_target: PathBuf::from("."),
show_windows_create_sandbox_hint: false,
should_quit: false,
selection: None,
@@ -207,6 +207,7 @@ mod tests {
let widget = TrustDirectoryWidget {
codex_home: codex_home.path().to_path_buf(),
cwd: PathBuf::from("/workspace/project"),
trust_target: PathBuf::from("/workspace/project"),
show_windows_create_sandbox_hint: false,
should_quit: false,
selection: None,
@@ -0,0 +1,11 @@
---
source: tui/src/history_cell.rs
expression: rendered
---
╭───────────────────────────────────────╮
│ >_ OpenAI Codex (vtest) │
│ │
│ model: gpt-5 /model to change │
│ directory: /tmp/project │
│ permissions: YOLO mode │
╰───────────────────────────────────────╯