Files
codex/codex-rs/core/tests/suite/permissions_messages.rs
Owen Linandcopyberry 12cb14f7b7 Default local threads to paginated history (#47900)
## What changed

- Make `LocalThreadStore` default to paginated history while retaining explicit legacy mode.
- Explicitly use paginated history for Guardian review sessions and allow in-memory stores to create paginated threads.
- Rename full-history resume, fork, and subagent helpers to identify them as legacy APIs, and document the paginated alternatives.
- Reject `thread/revert` for ephemeral threads and prevent the TUI from replaying or attaching to ephemeral agent threads.

## Testing

Add coverage for preserving the default and explicitly selected history modes and conversation context across restart, and for retaining context after rejected ephemeral reverts. Extend Guardian checkpoint coverage across both history modes and verify that saved reviews use paginated history.

GitOrigin-RevId: 870239eb3757018159ee4f230a009c97d38ac5b7
2026-09-24 18:07:37 +00:00

800 lines
27 KiB
Rust

use anyhow::Result;
use codex_config::ConfigLayerStack;
use codex_core::ForkSnapshot;
use codex_core::TurnInputRequest;
use codex_core::config::Constrained;
use codex_models_manager::model_info::model_info_from_slug;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ApprovalMessages;
use codex_protocol::openai_models::ModelMessages;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::openai_models::PermissionMessages;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ThreadSettingsOverrides;
use codex_protocol::user_input::UserInput;
use codex_utils_absolute_path::AbsolutePathBuf;
use core_test_support::responses::ResponsesRequest;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use std::collections::HashSet;
use tempfile::TempDir;
fn permissions_texts(request: &ResponsesRequest) -> Vec<String> {
request
.message_input_texts("developer")
.into_iter()
.filter(|text| text.contains("<permissions instructions>"))
.collect()
}
fn model_with_approval_messages(
slug: &str,
on_request: &str,
on_request_auto_review: &str,
) -> codex_protocol::openai_models::ModelInfo {
let mut model = model_info_from_slug(slug);
model.model_messages = Some(ModelMessages {
approvals: Some(ApprovalMessages {
on_request: Some(on_request.to_string()),
on_request_auto_review: Some(on_request_auto_review.to_string()),
never: None,
unless_trusted: None,
}),
..Default::default()
});
model
}
fn model_with_permission_messages(
slug: &str,
permissions: PermissionMessages,
) -> codex_protocol::openai_models::ModelInfo {
let mut model = model_info_from_slug(slug);
model.model_messages = Some(ModelMessages {
permissions: Some(permissions),
..Default::default()
});
model
}
async fn submit_text_turn(
test: &core_test_support::test_codex::TestCodex,
text: &str,
) -> Result<()> {
test.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: text.to_string(),
text_elements: Vec::new(),
}]))
.await?;
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn catalog_approval_message_is_sent_in_initial_permissions() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let req = mount_sse_once(
&server,
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
)
.await;
let model_slug = "catalog-approvals-model";
let override_instructions = "literal {{ personality }} override";
let model = model_with_approval_messages(
model_slug,
"catalog user approval instructions",
"catalog auto-review approval instructions",
);
let mut builder = test_codex()
.with_model(model_slug)
.with_config(move |config| {
config.base_instructions = Some(override_instructions.to_string());
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
config.model_catalog = Some(ModelsResponse {
models: vec![model],
});
});
let test = builder.build_with_auto_env(&server).await?;
submit_text_turn(&test, "hello").await?;
let request = req.single_request();
assert_eq!(request.instructions_text(), override_instructions);
let permissions = permissions_texts(&request);
assert_eq!(permissions.len(), 1);
assert!(permissions[0].contains("catalog user approval instructions"));
assert!(permissions[0].contains("Filesystem sandboxing defines"));
assert!(!permissions[0].contains("How to request escalation"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn model_change_appends_new_catalog_approval_message() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let _req1 = mount_sse_once(
&server,
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
)
.await;
let req2 = mount_sse_once(
&server,
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
)
.await;
let first_slug = "catalog-approvals-model-a";
let second_slug = "catalog-approvals-model-b";
let first = model_with_approval_messages(first_slug, "model A approvals", "model A auto");
let second = model_with_approval_messages(second_slug, "model B approvals", "model B auto");
let mut builder = test_codex()
.with_model(first_slug)
.with_config(move |config| {
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
config.model_catalog = Some(ModelsResponse {
models: vec![first, second],
});
});
let test = builder.build(&server).await?;
submit_text_turn(&test, "first").await?;
core_test_support::submit_thread_settings(
&test.codex,
ThreadSettingsOverrides {
model: Some(second_slug.to_string()),
..Default::default()
},
)
.await?;
submit_text_turn(&test, "second").await?;
let permissions = permissions_texts(&req2.single_request());
assert_eq!(permissions.len(), 2);
assert!(
permissions
.last()
.is_some_and(|text| text.contains("model B approvals"))
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn catalog_non_on_request_approval_messages_are_sent_in_initial_permissions() -> Result<()> {
skip_if_no_network!(Ok(()));
for (approval_policy, approvals, expected, unexpected) in [
(
AskForApproval::Never,
ApprovalMessages {
on_request: None,
on_request_auto_review: None,
never: Some("catalog never approval instructions".to_string()),
unless_trusted: None,
},
"catalog never approval instructions",
"Approval policy is currently never",
),
(
AskForApproval::UnlessTrusted,
ApprovalMessages {
on_request: None,
on_request_auto_review: None,
never: None,
unless_trusted: Some("catalog unless-trusted approval instructions".to_string()),
},
"catalog unless-trusted approval instructions",
"`approval_policy` is `unless-trusted`",
),
] {
let server = start_mock_server().await;
let req = mount_sse_once(
&server,
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
)
.await;
let model_slug = "catalog-non-on-request-approvals-model";
let mut model = model_info_from_slug(model_slug);
model.model_messages = Some(ModelMessages {
approvals: Some(approvals),
..Default::default()
});
let mut builder = test_codex()
.with_model(model_slug)
.with_config(move |config| {
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
config.model_catalog = Some(ModelsResponse {
models: vec![model],
});
});
let test = builder.build(&server).await?;
submit_text_turn(&test, "hello").await?;
let permissions = permissions_texts(&req.single_request());
assert_eq!(permissions.len(), 1);
assert!(permissions[0].contains(expected));
assert!(!permissions[0].contains(unexpected));
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn catalog_permission_message_is_sent_initially_and_after_model_change() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let _req1 = mount_sse_once(
&server,
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
)
.await;
let req2 = mount_sse_once(
&server,
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
)
.await;
let first_slug = "catalog-permissions-model-a";
let second_slug = "catalog-permissions-model-b";
let first = model_with_permission_messages(
first_slug,
PermissionMessages {
danger_full_access: None,
workspace_write: None,
read_only: Some("model A permissions: {{ network_access }}".to_string()),
},
);
let second = model_with_permission_messages(
second_slug,
PermissionMessages {
danger_full_access: None,
workspace_write: None,
read_only: Some("model B permissions: {{ network_access }}".to_string()),
},
);
let mut builder = test_codex()
.with_model(first_slug)
.with_config(move |config| {
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::Never);
config
.permissions
.set_permission_profile(PermissionProfile::read_only())
.expect("read-only permission profile should be allowed");
config.model_catalog = Some(ModelsResponse {
models: vec![first, second],
});
});
let test = builder.build(&server).await?;
submit_text_turn(&test, "first").await?;
core_test_support::submit_thread_settings(
&test.codex,
ThreadSettingsOverrides {
model: Some(second_slug.to_string()),
..Default::default()
},
)
.await?;
submit_text_turn(&test, "second").await?;
let permissions = permissions_texts(&req2.single_request());
assert_eq!(permissions.len(), 2);
assert!(permissions[0].contains("model A permissions: restricted"));
assert!(
permissions
.last()
.is_some_and(|text| text.contains("model B permissions: restricted"))
);
assert!(
permissions
.last()
.is_some_and(|text| text.contains("Approval policy is currently never"))
);
assert!(
!permissions
.iter()
.any(|text| text.contains("`sandbox_mode`"))
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn empty_catalog_permission_message_preserves_approval_instructions() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let req = mount_sse_once(
&server,
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
)
.await;
let model_slug = "empty-catalog-permissions-model";
let model = model_with_permission_messages(
model_slug,
PermissionMessages {
danger_full_access: None,
workspace_write: None,
read_only: Some(String::new()),
},
);
let mut builder = test_codex()
.with_model(model_slug)
.with_config(move |config| {
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::Never);
config
.permissions
.set_permission_profile(PermissionProfile::read_only())
.expect("read-only permission profile should be allowed");
config.model_catalog = Some(ModelsResponse {
models: vec![model],
});
});
let test = builder.build(&server).await?;
submit_text_turn(&test, "hello").await?;
let permissions = permissions_texts(&req.single_request());
assert_eq!(permissions.len(), 1);
assert!(permissions[0].contains("Approval policy is currently never"));
assert!(!permissions[0].contains("Filesystem sandboxing defines"));
assert!(!permissions[0].contains("`sandbox_mode`"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn permissions_message_sent_once_on_start() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let req = mount_sse_once(
&server,
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
)
.await;
let mut builder = test_codex().with_config(move |config| {
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
});
let test = builder.build(&server).await?;
test.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "hello".into(),
text_elements: Vec::new(),
}]))
.await?;
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
assert_eq!(permissions_texts(&req.single_request()).len(), 1);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn permissions_message_added_on_override_change() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let req1 = mount_sse_once(
&server,
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
)
.await;
let req2 = mount_sse_once(
&server,
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
)
.await;
let mut builder = test_codex().with_config(move |config| {
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
});
let test = builder.build(&server).await?;
test.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "hello 1".into(),
text_elements: Vec::new(),
}]))
.await?;
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
core_test_support::submit_thread_settings(
&test.codex,
ThreadSettingsOverrides {
approval_policy: Some(AskForApproval::Never),
..Default::default()
},
)
.await?;
test.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "hello 2".into(),
text_elements: Vec::new(),
}]))
.await?;
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let permissions_1 = permissions_texts(&req1.single_request());
let permissions_2 = permissions_texts(&req2.single_request());
assert_eq!(permissions_1.len(), 1);
assert_eq!(permissions_2.len(), 2);
let unique = permissions_2.into_iter().collect::<HashSet<String>>();
assert_eq!(unique.len(), 2);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn permissions_message_not_added_when_no_change() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let req1 = mount_sse_once(
&server,
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
)
.await;
let req2 = mount_sse_once(
&server,
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
)
.await;
let mut builder = test_codex().with_config(move |config| {
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
});
let test = builder.build(&server).await?;
test.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "hello 1".into(),
text_elements: Vec::new(),
}]))
.await?;
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
test.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "hello 2".into(),
text_elements: Vec::new(),
}]))
.await?;
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let permissions_1 = permissions_texts(&req1.single_request());
let permissions_2 = permissions_texts(&req2.single_request());
assert_eq!(permissions_1.len(), 1);
assert_eq!(permissions_2.len(), 1);
assert_eq!(permissions_1, permissions_2);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn permissions_message_omitted_when_disabled() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let req1 = mount_sse_once(
&server,
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
)
.await;
let req2 = mount_sse_once(
&server,
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
)
.await;
let mut builder = test_codex().with_config(move |config| {
config.include_permissions_instructions = false;
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
});
let test = builder.build(&server).await?;
test.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "hello 1".into(),
text_elements: Vec::new(),
}]))
.await?;
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
core_test_support::submit_thread_settings(
&test.codex,
ThreadSettingsOverrides {
approval_policy: Some(AskForApproval::Never),
..Default::default()
},
)
.await?;
test.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "hello 2".into(),
text_elements: Vec::new(),
}]))
.await?;
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
assert_eq!(
permissions_texts(&req1.single_request()),
Vec::<String>::new()
);
assert_eq!(
permissions_texts(&req2.single_request()),
Vec::<String>::new()
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn resume_replays_permissions_messages() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let _req1 = mount_sse_once(
&server,
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
)
.await;
let _req2 = mount_sse_once(
&server,
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
)
.await;
let req3 = mount_sse_once(
&server,
sse(vec![ev_response_created("resp-3"), ev_completed("resp-3")]),
)
.await;
let mut builder = test_codex().with_config(|config| {
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
});
let initial = builder.build(&server).await?;
initial
.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "hello 1".into(),
text_elements: Vec::new(),
}]))
.await?;
wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
core_test_support::submit_thread_settings(
&initial.codex,
ThreadSettingsOverrides {
approval_policy: Some(AskForApproval::Never),
..Default::default()
},
)
.await?;
initial
.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "hello 2".into(),
text_elements: Vec::new(),
}]))
.await?;
wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let resumed = builder.restart(&server, &initial).await?;
resumed
.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "after resume".into(),
text_elements: Vec::new(),
}]))
.await?;
wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let permissions = permissions_texts(&req3.single_request());
assert_eq!(permissions.len(), 3);
let unique = permissions.into_iter().collect::<HashSet<String>>();
assert_eq!(unique.len(), 2);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn resume_and_fork_append_permissions_messages() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let _req1 = mount_sse_once(
&server,
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
)
.await;
let req2 = mount_sse_once(
&server,
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
)
.await;
let req3 = mount_sse_once(
&server,
sse(vec![ev_response_created("resp-3"), ev_completed("resp-3")]),
)
.await;
let req4 = mount_sse_once(
&server,
sse(vec![ev_response_created("resp-4"), ev_completed("resp-4")]),
)
.await;
let mut builder = test_codex().with_config(|config| {
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
});
let initial = builder.build(&server).await?;
let rollout_path = initial
.session_configured
.rollout_path
.clone()
.expect("rollout path");
initial
.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "hello 1".into(),
text_elements: Vec::new(),
}]))
.await?;
wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
core_test_support::submit_thread_settings(
&initial.codex,
ThreadSettingsOverrides {
approval_policy: Some(AskForApproval::Never),
..Default::default()
},
)
.await?;
initial
.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "hello 2".into(),
text_elements: Vec::new(),
}]))
.await?;
wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let permissions_base = permissions_texts(&req2.single_request());
assert_eq!(permissions_base.len(), 2);
builder = builder.with_config(|config| {
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted);
});
let resumed = builder.restart(&server, &initial).await?;
resumed
.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "after resume".into(),
text_elements: Vec::new(),
}]))
.await?;
wait_for_event(&resumed.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let permissions_resume = permissions_texts(&req3.single_request());
assert_eq!(permissions_resume.len(), permissions_base.len() + 1);
assert_eq!(
&permissions_resume[..permissions_base.len()],
permissions_base.as_slice()
);
assert!(!permissions_base.contains(permissions_resume.last().expect("new permissions")));
let mut fork_config = initial.config.clone();
fork_config.permissions.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted);
let forked = initial
.thread_manager
.fork_legacy_thread(
ForkSnapshot::Interrupted,
codex_core::StartThreadOptions::new(fork_config.clone()),
rollout_path,
)
.await?;
forked
.thread
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "after fork".into(),
text_elements: Vec::new(),
}]))
.await?;
wait_for_event(&forked.thread, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let permissions_fork = permissions_texts(&req4.single_request());
assert_eq!(permissions_fork.len(), permissions_base.len() + 1);
assert_eq!(
&permissions_fork[..permissions_base.len()],
permissions_base.as_slice()
);
let new_permissions = &permissions_fork[permissions_base.len()..];
assert_eq!(new_permissions.len(), 1);
assert_eq!(permissions_fork, permissions_resume);
assert!(!permissions_base.contains(&new_permissions[0]));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn permissions_message_includes_writable_roots() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let req = mount_sse_once(
&server,
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
)
.await;
let writable = TempDir::new()?;
let writable_root = AbsolutePathBuf::try_from(writable.path().canonicalize()?)?;
let writable_root_for_config = writable_root.clone();
let permission_profile = PermissionProfile::workspace_write_with(
std::slice::from_ref(&writable_root),
NetworkSandboxPolicy::Restricted,
/*exclude_tmpdir_env_var*/ true,
/*exclude_slash_tmp*/ true,
);
let mut builder = test_codex().with_config(move |config| {
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
config
.permissions
.set_permission_profile(permission_profile)
.expect("test permission profile should be allowed");
let workspace_roots = vec![config.cwd.clone(), writable_root_for_config];
config.workspace_roots = workspace_roots.clone();
config.permissions.set_workspace_roots(workspace_roots);
config.config_layer_stack = ConfigLayerStack::default();
});
let test = builder.build(&server).await?;
test.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "hello".into(),
text_elements: Vec::new(),
}]))
.await?;
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let permissions = permissions_texts(&req.single_request());
assert_eq!(permissions.len(), 1);
let mut writable_roots = [
AbsolutePathBuf::try_from(test.config.cwd.as_path().canonicalize()?)?,
writable_root,
];
writable_roots.sort();
let writable_roots = writable_roots
.iter()
.map(|root| format!("`{}`", root.to_string_lossy()))
.collect::<Vec<_>>()
.join(", ");
let expected = format!(" The writable roots are {writable_roots}.");
let actual = permissions[0]
.lines()
.filter(|line| line.starts_with(" The writable root"))
.collect::<Vec<_>>();
assert_eq!(actual, vec![expected.as_str()]);
Ok(())
}