Remove core protocol dependency [2/2] (#20325)

## Why

With the local model layer and app-server routing in place from PR1,
this PR moves the active TUI runtime onto app-server notifications. The
affected pieces share the same event flow, so the command surface,
session state, bottom-pane prompts, chat rendering, history/status
views, and tests move together to keep the stacked branch buildable.

This PR also removes the obsolete compatibility surface that is no
longer used after the migration. The proposed protocol-boundary verifier
layer was dropped from the stack; enforcing that final boundary will be
simpler once `codex-tui` no longer needs any `codex_protocol`
references.

This PR is part 2 of a 2-PR stack:

1. Add TUI-owned replacement models and extract app-server event
routing.
2. Move the active TUI flow to app-server notifications and delete
obsolete adapter code.

## What changed

- Rewired app command and session handling to use app-server request and
notification shapes.
- Moved approval overlays, request-user-input flows, MCP elicitation,
realtime events, and review commands onto the app-server-facing model
surface.
- Updated chat rendering, history cells, status views, multi-agent UI,
replay state, and TUI tests to use app-server notifications plus the
local models introduced in PR1.
- Deleted `codex-rs/tui/src/app/app_server_adapter.rs` and the
superseded `chatwidget/tests/background_events.rs` fixture path.

## Verification

- `cargo check -p codex-tui --tests`
- Top of stack: `cargo test -p codex-tui`
This commit is contained in:
Eric Traut
2026-04-30 11:34:34 -07:00
committed by GitHub
parent 5cc5f12efc
commit f2bc2f26a9
76 changed files with 5078 additions and 9951 deletions
+48 -25
View File
@@ -6,31 +6,54 @@ async fn collab_spawn_end_shows_requested_model_and_effort() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
let sender_thread_id = ThreadId::new();
let spawned_thread_id = ThreadId::new();
chat.set_collab_agent_metadata(
spawned_thread_id,
Some("Robie".to_string()),
Some("explorer".to_string()),
);
chat.handle_codex_event(Event {
id: "spawn-begin".into(),
msg: EventMsg::CollabAgentSpawnBegin(CollabAgentSpawnBeginEvent {
call_id: "call-spawn".to_string(),
sender_thread_id,
prompt: "Explore the repo".to_string(),
model: "gpt-5".to_string(),
reasoning_effort: ReasoningEffortConfig::High,
chat.handle_server_notification(
ServerNotification::ItemStarted(ItemStartedNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item: AppServerThreadItem::CollabAgentToolCall {
id: "call-spawn".to_string(),
tool: AppServerCollabAgentTool::SpawnAgent,
status: AppServerCollabAgentToolCallStatus::InProgress,
sender_thread_id: sender_thread_id.to_string(),
receiver_thread_ids: Vec::new(),
prompt: Some("Explore the repo".to_string()),
model: Some("gpt-5".to_string()),
reasoning_effort: Some(ReasoningEffortConfig::High),
agents_states: HashMap::new(),
},
}),
});
chat.handle_codex_event(Event {
id: "spawn-end".into(),
msg: EventMsg::CollabAgentSpawnEnd(CollabAgentSpawnEndEvent {
call_id: "call-spawn".to_string(),
sender_thread_id,
new_thread_id: Some(spawned_thread_id),
new_agent_nickname: Some("Robie".to_string()),
new_agent_role: Some("explorer".to_string()),
prompt: "Explore the repo".to_string(),
model: "gpt-5".to_string(),
reasoning_effort: ReasoningEffortConfig::High,
status: AgentStatus::PendingInit,
/*replay_kind*/ None,
);
chat.handle_server_notification(
ServerNotification::ItemCompleted(ItemCompletedNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item: AppServerThreadItem::CollabAgentToolCall {
id: "call-spawn".to_string(),
tool: AppServerCollabAgentTool::SpawnAgent,
status: AppServerCollabAgentToolCallStatus::Completed,
sender_thread_id: sender_thread_id.to_string(),
receiver_thread_ids: vec![spawned_thread_id.to_string()],
prompt: Some("Explore the repo".to_string()),
model: None,
reasoning_effort: None,
agents_states: HashMap::from([(
spawned_thread_id.to_string(),
AppServerCollabAgentState {
status: AppServerCollabAgentStatus::PendingInit,
message: None,
},
)]),
},
}),
});
/*replay_kind*/ None,
);
let cells = drain_insert_history(&mut rx);
let rendered = cells
@@ -586,7 +609,7 @@ async fn live_app_server_stream_recovery_restores_previous_status_header() {
ServerNotification::Error(ErrorNotification {
error: AppServerTurnError {
message: "Reconnecting... 1/5".to_string(),
codex_error_info: Some(CodexErrorInfo::Other.into()),
codex_error_info: Some(CodexErrorInfo::Other),
additional_details: None,
},
will_retry: true,
@@ -643,7 +666,7 @@ async fn live_app_server_server_overloaded_error_renders_warning() {
ServerNotification::Error(ErrorNotification {
error: AppServerTurnError {
message: "server overloaded".to_string(),
codex_error_info: Some(CodexErrorInfo::ServerOverloaded.into()),
codex_error_info: Some(CodexErrorInfo::ServerOverloaded),
additional_details: None,
},
will_retry: false,
@@ -684,7 +707,7 @@ async fn live_app_server_cyber_policy_error_renders_dedicated_notice() {
ServerNotification::Error(ErrorNotification {
error: AppServerTurnError {
message: "server fallback message".to_string(),
codex_error_info: Some(CodexErrorInfo::CyberPolicy.into()),
codex_error_info: Some(CodexErrorInfo::CyberPolicy),
additional_details: None,
},
will_retry: false,
@@ -23,12 +23,8 @@ async fn exec_approval_emits_proposed_command_and_decision_history() {
proposed_network_policy_amendments: None,
additional_permissions: None,
available_decisions: None,
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
id: "sub-short".into(),
msg: EventMsg::ExecApprovalRequest(ev),
});
handle_exec_approval_request(&mut chat, "sub-short", ev);
let proposed_cells = drain_insert_history(&mut rx);
assert!(
@@ -126,21 +122,23 @@ fn app_server_exec_approval_request_preserves_permissions_context() {
assert_eq!(
request.network_approval_context,
Some(codex_protocol::protocol::NetworkApprovalContext {
Some(codex_app_server_protocol::NetworkApprovalContext {
host: "example.com".to_string(),
protocol: codex_protocol::protocol::NetworkApprovalProtocol::Socks5Tcp,
protocol: codex_app_server_protocol::NetworkApprovalProtocol::Socks5Tcp,
})
);
assert_eq!(
request.additional_permissions,
Some(codex_protocol::models::AdditionalPermissionProfile {
network: Some(NetworkPermissions {
Some(AppServerAdditionalPermissionProfile {
network: Some(AppServerAdditionalNetworkPermissions {
enabled: Some(true),
}),
file_system: Some(FileSystemPermissions::from_read_write_roots(
Some(vec![read_path]),
Some(vec![write_path]),
)),
file_system: Some(AppServerAdditionalFileSystemPermissions {
read: Some(vec![read_path]),
write: Some(vec![write_path]),
glob_scan_max_depth: None,
entries: None,
}),
})
);
}
@@ -192,9 +190,10 @@ fn app_server_request_permissions_preserves_file_system_permissions() {
async fn exec_approval_uses_approval_id_when_present() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_codex_event(Event {
id: "sub-short".into(),
msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
handle_exec_approval_request(
&mut chat,
"sub-short",
ExecApprovalRequestEvent {
call_id: "call-parent".into(),
approval_id: Some("approval-subcommand".into()),
turn_id: "turn-short".into(),
@@ -208,21 +207,23 @@ async fn exec_approval_uses_approval_id_when_present() {
proposed_network_policy_amendments: None,
additional_permissions: None,
available_decisions: None,
parsed_cmd: vec![],
}),
});
},
);
chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
let mut found = false;
while let Ok(app_ev) = rx.try_recv() {
if let AppEvent::SubmitThreadOp {
op: AppCommand::ExecApproval { id, decision, .. },
op: Op::ExecApproval { id, decision, .. },
..
} = app_ev
{
assert_eq!(id, "approval-subcommand");
assert_matches!(decision, codex_protocol::protocol::ReviewDecision::Approved);
assert_matches!(
decision,
codex_app_server_protocol::CommandExecutionApprovalDecision::Accept
);
found = true;
break;
}
@@ -248,12 +249,8 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() {
proposed_network_policy_amendments: None,
additional_permissions: None,
available_decisions: None,
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
id: "sub-multi".into(),
msg: EventMsg::ExecApprovalRequest(ev_multi),
});
handle_exec_approval_request(&mut chat, "sub-multi", ev_multi);
let proposed_multi = drain_insert_history(&mut rx);
assert!(
proposed_multi.is_empty(),
@@ -301,12 +298,8 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() {
proposed_network_policy_amendments: None,
additional_permissions: None,
available_decisions: None,
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
id: "sub-long".into(),
msg: EventMsg::ExecApprovalRequest(ev_long),
});
handle_exec_approval_request(&mut chat, "sub-long", ev_long);
let proposed_long = drain_insert_history(&mut rx);
assert!(
proposed_long.is_empty(),
@@ -1,18 +0,0 @@
use super::*;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn background_event_updates_status_header() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_codex_event(Event {
id: "bg-1".into(),
msg: EventMsg::BackgroundEvent(BackgroundEventEvent {
message: "Waiting for `vim`".to_string(),
}),
});
assert!(chat.bottom_pane.status_indicator_visible());
assert_eq!(chat.current_status.header, "Waiting for `vim`");
assert!(drain_insert_history(&mut rx).is_empty());
}
@@ -1,4 +1,11 @@
use super::*;
use codex_app_server_protocol::FileSystemAccessMode;
use codex_app_server_protocol::FileSystemPath;
use codex_app_server_protocol::FileSystemSandboxEntry;
use codex_app_server_protocol::FileSystemSpecialPath;
use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile;
use codex_app_server_protocol::PermissionProfileFileSystemPermissions;
use codex_app_server_protocol::PermissionProfileNetworkPermissions;
use pretty_assertions::assert_eq;
#[tokio::test]
@@ -7,9 +14,10 @@ async fn submission_preserves_text_elements_and_local_images() {
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = codex_protocol::protocol::SessionConfiguredEvent {
session_id: conversation_id,
let configured = crate::session_state::ThreadSessionState {
thread_id: conversation_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
@@ -19,17 +27,14 @@ async fn submission_preserves_text_elements_and_local_images() {
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
chat.handle_codex_event(Event {
id: "initial".into(),
msg: EventMsg::SessionConfigured(configured),
});
chat.handle_thread_session(configured);
drain_insert_history(&mut rx);
let placeholder = "[Image #1]";
@@ -59,7 +64,7 @@ async fn submission_preserves_text_elements_and_local_images() {
items[1],
UserInput::Text {
text: text.clone(),
text_elements: text_elements.clone(),
text_elements: text_elements.clone().into_iter().map(Into::into).collect(),
}
);
@@ -92,29 +97,31 @@ async fn submission_includes_configured_permission_profile() {
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let expected_permission_profile = PermissionProfile::Managed {
network: codex_protocol::permissions::NetworkSandboxPolicy::Restricted,
file_system: codex_protocol::models::ManagedFileSystemPermissions::Restricted {
let expected_permission_profile: PermissionProfile = AppServerPermissionProfile::Managed {
network: PermissionProfileNetworkPermissions { enabled: false },
file_system: PermissionProfileFileSystemPermissions::Restricted {
entries: vec![
codex_protocol::permissions::FileSystemSandboxEntry {
path: codex_protocol::permissions::FileSystemPath::Special {
value: codex_protocol::permissions::FileSystemSpecialPath::Root,
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: codex_protocol::permissions::FileSystemAccessMode::Read,
access: FileSystemAccessMode::Read,
},
codex_protocol::permissions::FileSystemSandboxEntry {
path: codex_protocol::permissions::FileSystemPath::GlobPattern {
FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: "/home/user/project/secrets/**".to_string(),
},
access: codex_protocol::permissions::FileSystemAccessMode::None,
access: FileSystemAccessMode::None,
},
],
glob_scan_max_depth: None,
},
};
let configured = codex_protocol::protocol::SessionConfiguredEvent {
session_id: conversation_id,
}
.into();
let configured = crate::session_state::ThreadSessionState {
thread_id: conversation_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
@@ -124,17 +131,14 @@ async fn submission_includes_configured_permission_profile() {
permission_profile: expected_permission_profile.clone(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
chat.handle_codex_event(Event {
id: "initial".into(),
msg: EventMsg::SessionConfigured(configured),
});
chat.handle_thread_session(configured);
drain_insert_history(&mut rx);
chat.bottom_pane.set_composer_text(
@@ -150,7 +154,7 @@ async fn submission_includes_configured_permission_profile() {
} => permission_profile,
other => panic!("expected Op::UserTurn, got {other:?}"),
};
assert_eq!(permission_profile, Some(expected_permission_profile));
assert_eq!(permission_profile, expected_permission_profile);
}
#[tokio::test]
@@ -159,13 +163,15 @@ async fn submission_keeps_profile_when_legacy_projection_is_external() {
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let expected_permission_profile = PermissionProfile::Managed {
network: codex_protocol::permissions::NetworkSandboxPolicy::Restricted,
file_system: codex_protocol::models::ManagedFileSystemPermissions::Unrestricted,
};
let configured = codex_protocol::protocol::SessionConfiguredEvent {
session_id: conversation_id,
let expected_permission_profile: PermissionProfile = AppServerPermissionProfile::Managed {
network: PermissionProfileNetworkPermissions { enabled: false },
file_system: PermissionProfileFileSystemPermissions::Unrestricted,
}
.into();
let configured = crate::session_state::ThreadSessionState {
thread_id: conversation_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
@@ -175,17 +181,14 @@ async fn submission_keeps_profile_when_legacy_projection_is_external() {
permission_profile: expected_permission_profile.clone(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
chat.handle_codex_event(Event {
id: "initial".into(),
msg: EventMsg::SessionConfigured(configured),
});
chat.handle_thread_session(configured);
drain_insert_history(&mut rx);
chat.bottom_pane
@@ -198,7 +201,7 @@ async fn submission_keeps_profile_when_legacy_projection_is_external() {
} => permission_profile,
other => panic!("expected Op::UserTurn, got {other:?}"),
};
assert_eq!(permission_profile, Some(expected_permission_profile));
assert_eq!(permission_profile, expected_permission_profile);
}
#[tokio::test]
@@ -207,9 +210,10 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = codex_protocol::protocol::SessionConfiguredEvent {
session_id: conversation_id,
let configured = crate::session_state::ThreadSessionState {
thread_id: conversation_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
@@ -219,17 +223,14 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
chat.handle_codex_event(Event {
id: "initial".into(),
msg: EventMsg::SessionConfigured(configured),
});
chat.handle_thread_session(configured);
drain_insert_history(&mut rx);
let remote_url = "https://example.com/remote.png".to_string();
@@ -256,7 +257,7 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi
assert_eq!(
items[0],
UserInput::Image {
image_url: remote_url.clone(),
url: remote_url.clone(),
}
);
assert_eq!(
@@ -269,7 +270,7 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi
items[2],
UserInput::Text {
text: text.clone(),
text_elements: text_elements.clone(),
text_elements: text_elements.clone().into_iter().map(Into::into).collect(),
}
);
assert_eq!(text_elements[0].placeholder(&text), Some("[Image #2]"));
@@ -303,9 +304,10 @@ async fn enter_with_only_remote_images_submits_user_turn() {
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = codex_protocol::protocol::SessionConfiguredEvent {
session_id: conversation_id,
let configured = crate::session_state::ThreadSessionState {
thread_id: conversation_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
@@ -315,17 +317,14 @@ async fn enter_with_only_remote_images_submits_user_turn() {
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
chat.handle_codex_event(Event {
id: "initial".into(),
msg: EventMsg::SessionConfigured(configured),
});
chat.handle_thread_session(configured);
drain_insert_history(&mut rx);
let remote_url = "https://example.com/remote-only.png".to_string();
@@ -341,7 +340,7 @@ async fn enter_with_only_remote_images_submits_user_turn() {
assert_eq!(
items,
vec![UserInput::Image {
image_url: remote_url.clone(),
url: remote_url.clone(),
}]
);
assert_eq!(summary, None);
@@ -369,9 +368,10 @@ async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() {
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = codex_protocol::protocol::SessionConfiguredEvent {
session_id: conversation_id,
let configured = crate::session_state::ThreadSessionState {
thread_id: conversation_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
@@ -381,17 +381,14 @@ async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() {
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
chat.handle_codex_event(Event {
id: "initial".into(),
msg: EventMsg::SessionConfigured(configured),
});
chat.handle_thread_session(configured);
drain_insert_history(&mut rx);
let remote_url = "https://example.com/remote-only.png".to_string();
@@ -410,9 +407,10 @@ async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() {
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = codex_protocol::protocol::SessionConfiguredEvent {
session_id: conversation_id,
let configured = crate::session_state::ThreadSessionState {
thread_id: conversation_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
@@ -422,17 +420,14 @@ async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() {
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
chat.handle_codex_event(Event {
id: "initial".into(),
msg: EventMsg::SessionConfigured(configured),
});
chat.handle_thread_session(configured);
drain_insert_history(&mut rx);
let remote_url = "https://example.com/remote-only.png".to_string();
@@ -451,9 +446,10 @@ async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() {
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = codex_protocol::protocol::SessionConfiguredEvent {
session_id: conversation_id,
let configured = crate::session_state::ThreadSessionState {
thread_id: conversation_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
@@ -463,17 +459,14 @@ async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() {
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
chat.handle_codex_event(Event {
id: "initial".into(),
msg: EventMsg::SessionConfigured(configured),
});
chat.handle_thread_session(configured);
drain_insert_history(&mut rx);
let remote_url = "https://example.com/remote-only.png".to_string();
@@ -495,9 +488,10 @@ async fn submission_prefers_selected_duplicate_skill_path() {
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = codex_protocol::protocol::SessionConfiguredEvent {
session_id: conversation_id,
let configured = crate::session_state::ThreadSessionState {
thread_id: conversation_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
@@ -507,17 +501,14 @@ async fn submission_prefers_selected_duplicate_skill_path() {
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
chat.handle_codex_event(Event {
id: "initial".into(),
msg: EventMsg::SessionConfigured(configured),
});
chat.handle_thread_session(configured);
drain_insert_history(&mut rx);
let repo_skill_path = test_path_buf("/tmp/repo/figma/SKILL.md").abs();
@@ -531,7 +522,7 @@ async fn submission_prefers_selected_duplicate_skill_path() {
dependencies: None,
policy: None,
path_to_skills_md: repo_skill_path,
scope: SkillScope::Repo,
scope: crate::test_support::skill_scope_repo(),
},
SkillMetadata {
name: "figma".to_string(),
@@ -541,7 +532,7 @@ async fn submission_prefers_selected_duplicate_skill_path() {
dependencies: None,
policy: None,
path_to_skills_md: user_skill_path.clone(),
scope: SkillScope::User,
scope: crate::test_support::skill_scope_user(),
},
]));
@@ -738,15 +729,7 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() {
);
chat.refresh_pending_input_preview();
chat.handle_codex_event(Event {
id: "interrupt".into(),
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::Interrupted,
completed_at: None,
duration_ms: None,
}),
});
handle_turn_interrupted(&mut chat, "turn-1");
assert_eq!(chat.bottom_pane.composer_text(), "Implement the plan.");
assert!(chat.queued_user_messages.is_empty());
@@ -1129,15 +1112,15 @@ async fn enqueueing_history_prompt_multiple_times_is_stable() {
}
#[test]
fn rendered_user_message_event_from_inputs_matches_flattened_user_message_shape() {
fn user_message_display_from_inputs_matches_flattened_user_message_shape() {
let local_image = PathBuf::from("/tmp/local.png");
let rendered = ChatWidget::rendered_user_message_event_from_inputs(&[
let rendered = ChatWidget::user_message_display_from_inputs(&[
UserInput::Text {
text: "hello ".to_string(),
text_elements: vec![TextElement::new((0..5).into(), /*placeholder*/ None)],
text_elements: vec![TextElement::new((0..5).into(), /*placeholder*/ None).into()],
},
UserInput::Image {
image_url: "https://example.com/remote.png".to_string(),
url: "https://example.com/remote.png".to_string(),
},
UserInput::LocalImage {
path: local_image.clone(),
@@ -1152,13 +1135,13 @@ fn rendered_user_message_event_from_inputs_matches_flattened_user_message_shape(
},
UserInput::Text {
text: "world".to_string(),
text_elements: vec![TextElement::new((0..5).into(), Some("planet".to_string()))],
text_elements: vec![TextElement::new((0..5).into(), Some("planet".to_string())).into()],
},
]);
assert_eq!(
rendered,
ChatWidget::rendered_user_message_event_from_parts(
ChatWidget::user_message_display_from_parts(
"hello world".to_string(),
vec![
TextElement::new((0..5).into(), Some("hello".to_string())),
@@ -1184,16 +1167,8 @@ async fn interrupt_restores_queued_messages_into_composer() {
.push_back(UserMessage::from("second queued".to_string()).into());
chat.refresh_pending_input_preview();
// Deliver a TurnAborted event with Interrupted reason (as if Esc was pressed).
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::Interrupted,
completed_at: None,
duration_ms: None,
}),
});
// Deliver an interrupted turn notification as if Esc was pressed.
handle_turn_interrupted(&mut chat, "turn-1");
// Composer should now contain the queued messages joined by newlines, in order.
assert_eq!(
@@ -1226,15 +1201,7 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() {
.push_back(UserMessage::from("second queued".to_string()).into());
chat.refresh_pending_input_preview();
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::Interrupted,
completed_at: None,
duration_ms: None,
}),
});
handle_turn_interrupted(&mut chat, "turn-1");
assert_eq!(
chat.bottom_pane.composer_text(),
+146 -364
View File
@@ -20,12 +20,8 @@ async fn exec_approval_emits_proposed_command_and_decision_history() {
proposed_network_policy_amendments: None,
additional_permissions: None,
available_decisions: None,
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
id: "sub-short".into(),
msg: EventMsg::ExecApprovalRequest(ev),
});
handle_exec_approval_request(&mut chat, "sub-short", ev);
let proposed_cells = drain_insert_history(&mut rx);
assert!(
@@ -89,9 +85,10 @@ fn app_server_exec_approval_request_splits_shell_wrapped_command() {
async fn exec_approval_uses_approval_id_when_present() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_codex_event(Event {
id: "sub-short".into(),
msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
handle_exec_approval_request(
&mut chat,
"sub-short",
ExecApprovalRequestEvent {
call_id: "call-parent".into(),
approval_id: Some("approval-subcommand".into()),
turn_id: "turn-short".into(),
@@ -105,21 +102,23 @@ async fn exec_approval_uses_approval_id_when_present() {
proposed_network_policy_amendments: None,
additional_permissions: None,
available_decisions: None,
parsed_cmd: vec![],
}),
});
},
);
chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
let mut found = false;
while let Ok(app_ev) = rx.try_recv() {
if let AppEvent::SubmitThreadOp {
op: AppCommand::ExecApproval { id, decision, .. },
op: Op::ExecApproval { id, decision, .. },
..
} = app_ev
{
assert_eq!(id, "approval-subcommand");
assert_matches!(decision, codex_protocol::protocol::ReviewDecision::Approved);
assert_matches!(
decision,
codex_app_server_protocol::CommandExecutionApprovalDecision::Accept
);
found = true;
break;
}
@@ -146,12 +145,8 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() {
proposed_network_policy_amendments: None,
additional_permissions: None,
available_decisions: None,
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
id: "sub-multi".into(),
msg: EventMsg::ExecApprovalRequest(ev_multi),
});
handle_exec_approval_request(&mut chat, "sub-multi", ev_multi);
let proposed_multi = drain_insert_history(&mut rx);
assert!(
proposed_multi.is_empty(),
@@ -201,12 +196,8 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() {
proposed_network_policy_amendments: None,
additional_permissions: None,
available_decisions: None,
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
id: "sub-long".into(),
msg: EventMsg::ExecApprovalRequest(ev_long),
});
handle_exec_approval_request(&mut chat, "sub-long", ev_long);
let proposed_long = drain_insert_history(&mut rx);
assert!(
proposed_long.is_empty(),
@@ -356,28 +347,26 @@ async fn exec_end_without_begin_uses_event_command() {
"-lc".to_string(),
"echo orphaned".to_string(),
];
let parsed_cmd = codex_shell_command::parse_command::parse_command(&command);
let cwd = AbsolutePathBuf::current_dir().expect("current dir");
chat.handle_codex_event(Event {
id: "call-orphan".to_string(),
msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id: "call-orphan".to_string(),
process_id: None,
turn_id: "turn-1".to_string(),
command,
let command_actions = codex_shell_command::parse_command::parse_command(&command)
.into_iter()
.map(|parsed| AppServerCommandAction::from_core_with_cwd(parsed, &chat.config.cwd))
.collect();
let cwd = chat.config.cwd.clone();
handle_exec_end(
&mut chat,
AppServerThreadItem::CommandExecution {
id: "call-orphan".to_string(),
command: codex_shell_command::parse_command::shlex_join(&command),
cwd,
parsed_cmd,
process_id: None,
source: ExecCommandSource::Agent,
interaction_input: None,
stdout: "done".to_string(),
stderr: String::new(),
aggregated_output: "done".to_string(),
exit_code: 0,
duration: std::time::Duration::from_millis(5),
formatted_output: "done".to_string(),
status: CoreExecCommandStatus::Completed,
}),
});
status: AppServerCommandExecutionStatus::Completed,
command_actions,
aggregated_output: Some("done".to_string()),
exit_code: Some(0),
duration_ms: Some(5),
},
);
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected finalized exec cell to flush");
@@ -618,14 +607,7 @@ async fn unified_exec_interaction_after_task_complete_is_suppressed() {
/*last_agent_message*/ None, /*duration_ms*/ None, /*from_replay*/ false,
);
chat.handle_codex_event(Event {
id: "call-1".to_string(),
msg: EventMsg::TerminalInteraction(TerminalInteractionEvent {
call_id: "call-1".to_string(),
process_id: "proc-1".to_string(),
stdin: "ls\n".to_string(),
}),
});
terminal_interaction(&mut chat, "call-1", "proc-1", "ls\n");
let cells = drain_insert_history(&mut rx);
assert!(
@@ -637,30 +619,13 @@ async fn unified_exec_interaction_after_task_complete_is_suppressed() {
#[tokio::test]
async fn unified_exec_wait_after_final_agent_message_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
handle_turn_started(&mut chat, "turn-1");
begin_unified_exec_startup(&mut chat, "call-wait", "proc-1", "cargo test -p codex-core");
terminal_interaction(&mut chat, "call-wait-stdin", "proc-1", "");
complete_assistant_message(&mut chat, "msg-1", "Final response.", /*phase*/ None);
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-1".to_string(),
last_agent_message: Some("Final response.".into()),
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
}),
});
handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None);
let cells = drain_insert_history(&mut rx);
let combined = cells
@@ -673,15 +638,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() {
#[tokio::test]
async fn unified_exec_wait_before_streamed_agent_message_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
handle_turn_started(&mut chat, "turn-1");
begin_unified_exec_startup(
&mut chat,
@@ -691,22 +648,8 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() {
);
terminal_interaction(&mut chat, "call-wait-stream-stdin", "proc-1", "");
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
delta: "Streaming response.".into(),
}),
});
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-1".to_string(),
last_agent_message: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
}),
});
handle_agent_message_delta(&mut chat, "Streaming response.");
handle_turn_completed(&mut chat, "turn-wait-1", /*duration_ms*/ None);
let cells = drain_insert_history(&mut rx);
let combined = cells
@@ -719,15 +662,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() {
#[tokio::test]
async fn final_worked_for_uses_cumulative_turn_duration_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
handle_turn_started(&mut chat, "turn-1");
let exec = begin_exec_with_source(
&mut chat,
@@ -743,16 +678,7 @@ async fn final_worked_for_uses_cumulative_turn_duration_snapshot() {
"Final response.",
Some(MessagePhase::FinalAnswer),
);
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-1".to_string(),
last_agent_message: Some("Final response.".to_string()),
completed_at: None,
duration_ms: Some(125_000),
time_to_first_token_ms: None,
}),
});
handle_turn_completed(&mut chat, "turn-1", Some(125_000));
let cells = drain_insert_history(&mut rx);
let combined = cells
@@ -777,11 +703,7 @@ async fn unified_exec_wait_status_header_updates_on_late_command_display() {
recent_chunks: Vec::new(),
});
chat.on_terminal_interaction(TerminalInteractionEvent {
call_id: "call-1".to_string(),
process_id: "proc-1".to_string(),
stdin: String::new(),
});
terminal_interaction(&mut chat, "call-1", "proc-1", "");
assert!(chat.active_cell.is_none());
assert_eq!(
@@ -815,16 +737,7 @@ async fn unified_exec_waiting_multiple_empty_snapshots() {
assert_eq!(status.header(), "Waiting for background terminal");
assert_eq!(status.details(), Some("just fix"));
chat.handle_codex_event(Event {
id: "turn-wait-1".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-1".to_string(),
last_agent_message: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
}),
});
handle_turn_completed(&mut chat, "turn-wait-3", /*duration_ms*/ None);
let cells = drain_insert_history(&mut rx);
let combined = cells
@@ -896,16 +809,7 @@ async fn unified_exec_non_empty_then_empty_snapshots() {
.collect::<String>();
assert_chatwidget_snapshot!("unified_exec_non_empty_then_empty_active", active_combined);
chat.handle_codex_event(Event {
id: "turn-wait-3".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-1".to_string(),
last_agent_message: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
}),
});
handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None);
let post_cells = drain_insert_history(&mut rx);
let mut combined = pre_cells
@@ -928,13 +832,7 @@ async fn view_image_tool_call_adds_history_cell() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let image_path = chat.config.cwd.join("example.png");
chat.handle_codex_event(Event {
id: "sub-image".into(),
msg: EventMsg::ViewImageToolCall(ViewImageToolCallEvent {
call_id: "call-image".into(),
path: image_path,
}),
});
handle_view_image_tool_call(&mut chat, "call-image", image_path);
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected a single history cell");
@@ -946,16 +844,12 @@ async fn view_image_tool_call_adds_history_cell() {
async fn image_generation_call_adds_history_cell() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_codex_event(Event {
id: "sub-image-generation".into(),
msg: EventMsg::ImageGenerationEnd(ImageGenerationEndEvent {
call_id: "call-image-generation".into(),
status: "completed".into(),
revised_prompt: Some("A tiny blue square".into()),
result: "Zm9v".into(),
saved_path: Some(test_path_buf("/tmp/ig-1.png").abs()),
}),
});
handle_image_generation_end(
&mut chat,
"call-image-generation",
Some("A tiny blue square".into()),
Some(test_path_buf("/tmp/ig-1.png").abs()),
);
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected a single history cell");
@@ -1049,9 +943,10 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = codex_protocol::protocol::SessionConfiguredEvent {
session_id: conversation_id,
let configured = crate::session_state::ThreadSessionState {
thread_id: conversation_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
@@ -1061,28 +956,17 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() {
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
chat.handle_codex_event(Event {
id: "initial".into(),
msg: EventMsg::SessionConfigured(configured),
});
chat.handle_thread_session(configured);
drain_insert_history(&mut rx);
while op_rx.try_recv().is_ok() {}
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
handle_turn_started(&mut chat, "turn-1");
chat.bottom_pane
.set_composer_text("!echo hi".to_string(), Vec::new(), Vec::new());
@@ -1103,15 +987,7 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() {
async fn user_message_during_user_shell_command_is_queued_not_steered() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
handle_turn_started(&mut chat, "turn-1");
let begin = begin_exec_with_source(
&mut chat,
"user-shell-sleep",
@@ -1128,16 +1004,13 @@ async fn user_message_during_user_shell_command_is_queued_not_steered() {
assert_eq!(chat.queued_user_message_texts(), vec!["hi".to_string()]);
end_exec(&mut chat, begin, "", "", /*exit_code*/ 0);
chat.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-1".to_string(),
last_agent_message: Some("done".to_string()),
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
}),
});
complete_assistant_message(
&mut chat,
"msg-done",
"done",
Some(MessagePhase::FinalAnswer),
);
handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None);
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
@@ -1174,7 +1047,7 @@ async fn disabled_slash_command_while_task_running_snapshot() {
//
// Snapshot test: command approval modal
//
// Synthesizes a Codex ExecApprovalRequest event to trigger the approval modal
// Synthesizes an exec approval request to trigger the approval modal
// and snapshots the visual output using the ratatui TestBackend.
#[tokio::test]
async fn approval_modal_exec_snapshot() -> anyhow::Result<()> {
@@ -1184,7 +1057,7 @@ async fn approval_modal_exec_snapshot() -> anyhow::Result<()> {
chat.config
.permissions
.approval_policy
.set(AskForApproval::OnRequest)?;
.set(AskForApproval::OnRequest.to_core())?;
// Inject an exec approval request to display the approval modal.
let ev = ExecApprovalRequestEvent {
call_id: "call-approve-cmd".into(),
@@ -1196,20 +1069,14 @@ async fn approval_modal_exec_snapshot() -> anyhow::Result<()> {
"this is a test reason such as one that would be produced by the model".into(),
),
network_approval_context: None,
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
"echo".into(),
"hello".into(),
"world".into(),
])),
proposed_execpolicy_amendment: Some(ExecPolicyAmendment {
command: vec!["echo".into(), "hello".into(), "world".into()],
}),
proposed_network_policy_amendments: None,
additional_permissions: None,
available_decisions: None,
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
id: "sub-approve".into(),
msg: EventMsg::ExecApprovalRequest(ev),
});
handle_exec_approval_request(&mut chat, "sub-approve", ev);
// Render to a fixed-size test terminal and snapshot.
// Call desired_height first and use that exact height for rendering.
let width = 100;
@@ -1247,7 +1114,7 @@ async fn approval_modal_exec_without_reason_snapshot() -> anyhow::Result<()> {
chat.config
.permissions
.approval_policy
.set(AskForApproval::OnRequest)?;
.set(AskForApproval::OnRequest.to_core())?;
let ev = ExecApprovalRequestEvent {
call_id: "call-approve-cmd-noreason".into(),
@@ -1257,20 +1124,14 @@ async fn approval_modal_exec_without_reason_snapshot() -> anyhow::Result<()> {
cwd: AbsolutePathBuf::current_dir().expect("current dir"),
reason: None,
network_approval_context: None,
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
"echo".into(),
"hello".into(),
"world".into(),
])),
proposed_execpolicy_amendment: Some(ExecPolicyAmendment {
command: vec!["echo".into(), "hello".into(), "world".into()],
}),
proposed_network_policy_amendments: None,
additional_permissions: None,
available_decisions: None,
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
id: "sub-approve-noreason".into(),
msg: EventMsg::ExecApprovalRequest(ev),
});
handle_exec_approval_request(&mut chat, "sub-approve-noreason", ev);
let width = 100;
let height = chat.desired_height(width);
@@ -1297,7 +1158,7 @@ async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot()
chat.config
.permissions
.approval_policy
.set(AskForApproval::OnRequest)?;
.set(AskForApproval::OnRequest.to_core())?;
let script = "python - <<'PY'\nprint('hello')\nPY".to_string();
let command = vec!["bash".into(), "-lc".into(), script];
@@ -1309,16 +1170,12 @@ async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot()
cwd: AbsolutePathBuf::current_dir().expect("current dir"),
reason: None,
network_approval_context: None,
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)),
proposed_execpolicy_amendment: Some(ExecPolicyAmendment { command }),
proposed_network_policy_amendments: None,
additional_permissions: None,
available_decisions: None,
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
id: "sub-approve-multiline-trunc".into(),
msg: EventMsg::ExecApprovalRequest(ev),
});
handle_exec_approval_request(&mut chat, "sub-approve-multiline-trunc", ev);
let width = 100;
let height = chat.desired_height(width);
@@ -1345,7 +1202,7 @@ async fn approval_modal_patch_snapshot() -> anyhow::Result<()> {
chat.config
.permissions
.approval_policy
.set(AskForApproval::OnRequest)?;
.set(AskForApproval::OnRequest.to_core())?;
// Build a small changeset and a reason/grant_root to exercise the prompt text.
let mut changes = HashMap::new();
@@ -1362,10 +1219,7 @@ async fn approval_modal_patch_snapshot() -> anyhow::Result<()> {
reason: Some("The model wants to apply changes".into()),
grant_root: Some(PathBuf::from("/tmp")),
};
chat.handle_codex_event(Event {
id: "sub-approve-patch".into(),
msg: EventMsg::ApplyPatchApprovalRequest(ev),
});
handle_apply_patch_approval_request(&mut chat, "sub-approve-patch", ev);
// Render at the widget's desired height and snapshot.
let height = chat.desired_height(/*width*/ 80);
@@ -1390,15 +1244,7 @@ async fn interrupt_preserves_unified_exec_processes() {
begin_unified_exec_startup(&mut chat, "call-2", "process-2", "sleep 6");
assert_eq!(chat.unified_exec_processes.len(), 2);
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::Interrupted,
completed_at: None,
duration_ms: None,
}),
});
handle_turn_interrupted(&mut chat, "turn-1");
assert_eq!(chat.unified_exec_processes.len(), 2);
@@ -1425,28 +1271,12 @@ async fn interrupt_preserves_unified_exec_processes() {
async fn interrupt_preserves_unified_exec_wait_streak_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
handle_turn_started(&mut chat, "turn-1");
let begin = begin_unified_exec_startup(&mut chat, "call-1", "process-1", "just fix");
terminal_interaction(&mut chat, "call-1a", "process-1", "");
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::Interrupted,
completed_at: None,
duration_ms: None,
}),
});
handle_turn_interrupted(&mut chat, "turn-1");
end_exec(&mut chat, begin, "", "", /*exit_code*/ 0);
let cells = drain_insert_history(&mut rx);
@@ -1467,16 +1297,7 @@ async fn turn_complete_keeps_unified_exec_processes() {
begin_unified_exec_startup(&mut chat, "call-2", "process-2", "sleep 6");
assert_eq!(chat.unified_exec_processes.len(), 2);
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-1".to_string(),
last_agent_message: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
}),
});
handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None);
assert_eq!(chat.unified_exec_processes.len(), 2);
@@ -1518,10 +1339,7 @@ async fn apply_patch_events_emit_history_cells() {
reason: None,
grant_root: None,
};
chat.handle_codex_event(Event {
id: "s1".into(),
msg: EventMsg::ApplyPatchApprovalRequest(ev),
});
handle_apply_patch_approval_request(&mut chat, "s1", ev);
assert!(
drain_insert_history(&mut rx).is_empty(),
"expected approval request to surface via modal without emitting history cells"
@@ -1535,16 +1353,7 @@ async fn apply_patch_events_emit_history_cells() {
content: "hello\n".to_string(),
},
);
let begin = PatchApplyBeginEvent {
call_id: "c1".into(),
turn_id: "turn-c1".into(),
auto_approved: true,
changes: changes2,
};
chat.handle_codex_event(Event {
id: "s1".into(),
msg: EventMsg::PatchApplyBegin(begin),
});
handle_patch_apply_begin(&mut chat, "c1", "turn-c1", changes2);
let cells = drain_insert_history(&mut rx);
assert!(!cells.is_empty(), "expected apply block cell to be sent");
let blob = lines_to_single_string(cells.last().unwrap());
@@ -1561,19 +1370,13 @@ async fn apply_patch_events_emit_history_cells() {
content: "hello\n".to_string(),
},
);
let end = PatchApplyEndEvent {
call_id: "c1".into(),
turn_id: "turn-c1".into(),
stdout: "ok\n".into(),
stderr: String::new(),
success: true,
changes: end_changes,
status: CorePatchApplyStatus::Completed,
};
chat.handle_codex_event(Event {
id: "s1".into(),
msg: EventMsg::PatchApplyEnd(end),
});
handle_patch_apply_end(
&mut chat,
"c1",
"turn-c1",
end_changes,
AppServerPatchApplyStatus::Completed,
);
let cells = drain_insert_history(&mut rx);
assert!(
cells.is_empty(),
@@ -1592,16 +1395,17 @@ async fn apply_patch_manual_approval_adjusts_header() {
content: "hello\n".to_string(),
},
);
chat.handle_codex_event(Event {
id: "s1".into(),
msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
handle_apply_patch_approval_request(
&mut chat,
"s1",
ApplyPatchApprovalRequestEvent {
call_id: "c1".into(),
turn_id: "turn-c1".into(),
changes: proposed_changes,
reason: None,
grant_root: None,
}),
});
},
);
drain_insert_history(&mut rx);
let mut apply_changes = HashMap::new();
@@ -1611,15 +1415,7 @@ async fn apply_patch_manual_approval_adjusts_header() {
content: "hello\n".to_string(),
},
);
chat.handle_codex_event(Event {
id: "s1".into(),
msg: EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
call_id: "c1".into(),
turn_id: "turn-c1".into(),
auto_approved: false,
changes: apply_changes,
}),
});
handle_patch_apply_begin(&mut chat, "c1", "turn-c1", apply_changes);
let cells = drain_insert_history(&mut rx);
assert!(!cells.is_empty(), "expected apply block cell to be sent");
@@ -1641,16 +1437,17 @@ async fn apply_patch_manual_flow_snapshot() {
content: "hello\n".to_string(),
},
);
chat.handle_codex_event(Event {
id: "s1".into(),
msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
handle_apply_patch_approval_request(
&mut chat,
"s1",
ApplyPatchApprovalRequestEvent {
call_id: "c1".into(),
turn_id: "turn-c1".into(),
changes: proposed_changes,
reason: Some("Manual review required".into()),
grant_root: None,
}),
});
},
);
let history_before_apply = drain_insert_history(&mut rx);
assert!(
history_before_apply.is_empty(),
@@ -1664,15 +1461,7 @@ async fn apply_patch_manual_flow_snapshot() {
content: "hello\n".to_string(),
},
);
chat.handle_codex_event(Event {
id: "s1".into(),
msg: EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
call_id: "c1".into(),
turn_id: "turn-c1".into(),
auto_approved: false,
changes: apply_changes,
}),
});
handle_patch_apply_begin(&mut chat, "c1", "turn-c1", apply_changes);
let approved_lines = drain_insert_history(&mut rx)
.pop()
.expect("approved patch cell");
@@ -1701,10 +1490,7 @@ async fn apply_patch_approval_sends_op_with_call_id() {
reason: None,
grant_root: None,
};
chat.handle_codex_event(Event {
id: "sub-123".into(),
msg: EventMsg::ApplyPatchApprovalRequest(ev),
});
handle_apply_patch_approval_request(&mut chat, "sub-123", ev);
// Approve via key press 'y'
chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
@@ -1713,12 +1499,15 @@ async fn apply_patch_approval_sends_op_with_call_id() {
let mut found = false;
while let Ok(app_ev) = rx.try_recv() {
if let AppEvent::SubmitThreadOp {
op: AppCommand::PatchApproval { id, decision },
op: Op::PatchApproval { id, decision },
..
} = app_ev
{
assert_eq!(id, "call-999");
assert_matches!(decision, codex_protocol::protocol::ReviewDecision::Approved);
assert_matches!(
decision,
codex_app_server_protocol::FileChangeApprovalDecision::Accept
);
found = true;
break;
}
@@ -1736,23 +1525,24 @@ async fn apply_patch_full_flow_integration_like() {
PathBuf::from("pkg.rs"),
FileChange::Add { content: "".into() },
);
chat.handle_codex_event(Event {
id: "sub-xyz".into(),
msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
handle_apply_patch_approval_request(
&mut chat,
"sub-xyz",
ApplyPatchApprovalRequestEvent {
call_id: "call-1".into(),
turn_id: "turn-call-1".into(),
changes,
reason: None,
grant_root: None,
}),
});
},
);
// 2) User approves via 'y' and App receives a thread-scoped op
chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
let mut maybe_op: Option<Op> = None;
while let Ok(app_ev) = rx.try_recv() {
if let AppEvent::SubmitThreadOp { op, .. } = app_ev {
maybe_op = Some(op.into_core());
maybe_op = Some(op);
break;
}
}
@@ -1766,7 +1556,10 @@ async fn apply_patch_full_flow_integration_like() {
match forwarded {
Op::PatchApproval { id, decision } => {
assert_eq!(id, "call-1");
assert_matches!(decision, codex_protocol::protocol::ReviewDecision::Approved);
assert_matches!(
decision,
codex_app_server_protocol::FileChangeApprovalDecision::Accept
);
}
other => panic!("unexpected op forwarded: {other:?}"),
}
@@ -1777,32 +1570,19 @@ async fn apply_patch_full_flow_integration_like() {
PathBuf::from("pkg.rs"),
FileChange::Add { content: "".into() },
);
chat.handle_codex_event(Event {
id: "sub-xyz".into(),
msg: EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
call_id: "call-1".into(),
turn_id: "turn-call-1".into(),
auto_approved: false,
changes: changes2,
}),
});
handle_patch_apply_begin(&mut chat, "call-1", "turn-call-1", changes2);
let mut end_changes = HashMap::new();
end_changes.insert(
PathBuf::from("pkg.rs"),
FileChange::Add { content: "".into() },
);
chat.handle_codex_event(Event {
id: "sub-xyz".into(),
msg: EventMsg::PatchApplyEnd(PatchApplyEndEvent {
call_id: "call-1".into(),
turn_id: "turn-call-1".into(),
stdout: String::from("ok"),
stderr: String::new(),
success: true,
changes: end_changes,
status: CorePatchApplyStatus::Completed,
}),
});
handle_patch_apply_end(
&mut chat,
"call-1",
"turn-call-1",
end_changes,
AppServerPatchApplyStatus::Completed,
);
}
#[tokio::test]
@@ -1812,7 +1592,7 @@ async fn apply_patch_untrusted_shows_approval_modal() -> anyhow::Result<()> {
chat.config
.permissions
.approval_policy
.set(AskForApproval::OnRequest)?;
.set(AskForApproval::OnRequest.to_core())?;
// Simulate a patch approval request from backend
let mut changes = HashMap::new();
@@ -1820,16 +1600,17 @@ async fn apply_patch_untrusted_shows_approval_modal() -> anyhow::Result<()> {
PathBuf::from("a.rs"),
FileChange::Add { content: "".into() },
);
chat.handle_codex_event(Event {
id: "sub-1".into(),
msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
handle_apply_patch_approval_request(
&mut chat,
"sub-1",
ApplyPatchApprovalRequestEvent {
call_id: "call-1".into(),
turn_id: "turn-call-1".into(),
changes,
reason: None,
grant_root: None,
}),
});
},
);
// Render and ensure the approval modal title is present
let area = Rect::new(0, 0, 80, 12);
@@ -1863,7 +1644,7 @@ async fn apply_patch_request_omits_diff_summary_from_modal() -> anyhow::Result<(
chat.config
.permissions
.approval_policy
.set(AskForApproval::OnRequest)?;
.set(AskForApproval::OnRequest.to_core())?;
// Simulate backend asking to apply a patch adding two lines to README.md
let mut changes = HashMap::new();
@@ -1874,16 +1655,17 @@ async fn apply_patch_request_omits_diff_summary_from_modal() -> anyhow::Result<(
content: "line one\nline two\n".into(),
},
);
chat.handle_codex_event(Event {
id: "sub-apply".into(),
msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
handle_apply_patch_approval_request(
&mut chat,
"sub-apply",
ApplyPatchApprovalRequestEvent {
call_id: "call-apply".into(),
turn_id: "turn-apply".into(),
changes,
reason: None,
grant_root: None,
}),
});
},
);
assert!(
drain_insert_history(&mut rx).is_empty(),
+137 -188
View File
@@ -23,10 +23,7 @@ fn auto_review_denial_event() -> GuardianAssessmentEvent {
async fn auto_review_denials_popup_lists_stored_auto_review_denials() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "guardian-assessment".into(),
msg: EventMsg::GuardianAssessment(auto_review_denial_event()),
});
chat.on_guardian_assessment(auto_review_denial_event());
drain_insert_history(&mut rx);
chat.open_auto_review_denials_popup();
@@ -40,10 +37,7 @@ async fn approving_recent_denial_emits_structured_core_op_once() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
chat.handle_codex_event(Event {
id: "guardian-assessment".into(),
msg: EventMsg::GuardianAssessment(auto_review_denial_event()),
});
chat.on_guardian_assessment(auto_review_denial_event());
drain_insert_history(&mut rx);
chat.approve_recent_auto_review_denial(thread_id, "auto-review-recent-1".to_string());
@@ -52,7 +46,7 @@ async fn approving_recent_denial_emits_structured_core_op_once() {
rx.try_recv(),
Ok(AppEvent::SubmitThreadOp {
thread_id: submitted_thread_id,
op: AppCommand::ApproveGuardianDeniedAction { event }
op: Op::ApproveGuardianDeniedAction { event }
}) if submitted_thread_id == thread_id
&& event.id == "auto-review-recent-1"
&& event.status == GuardianAssessmentStatus::Denied
@@ -75,39 +69,28 @@ async fn guardian_denied_exec_renders_warning_and_denied_request() {
cwd: test_path_buf("/tmp").abs(),
};
chat.handle_codex_event(Event {
id: "guardian-in-progress".into(),
msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent {
id: "guardian-1".into(),
target_item_id: Some("guardian-target-1".into()),
turn_id: "turn-1".into(),
status: GuardianAssessmentStatus::InProgress,
risk_level: None,
user_authorization: None,
rationale: None,
decision_source: None,
action: action.clone(),
}),
chat.on_guardian_assessment(GuardianAssessmentEvent {
id: "guardian-1".into(),
target_item_id: Some("guardian-target-1".into()),
turn_id: "turn-1".into(),
status: GuardianAssessmentStatus::InProgress,
risk_level: None,
user_authorization: None,
rationale: None,
decision_source: None,
action: action.clone(),
});
chat.handle_codex_event(Event {
id: "guardian-warning".into(),
msg: EventMsg::GuardianWarning(WarningEvent {
message: "Automatic approval review denied (risk: high): The planned action would transmit the full contents of a workspace source file (`core/src/codex.rs`) to `https://example.com`, which is an external and untrusted endpoint.".into(),
}),
});
chat.handle_codex_event(Event {
id: "guardian-assessment".into(),
msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent {
id: "guardian-1".into(),
target_item_id: Some("guardian-target-1".into()),
turn_id: "turn-1".into(),
status: GuardianAssessmentStatus::Denied,
risk_level: Some(GuardianRiskLevel::High),
user_authorization: Some(GuardianUserAuthorization::Low),
rationale: Some("Would exfiltrate local source code.".into()),
decision_source: Some(GuardianAssessmentDecisionSource::Agent),
action,
}),
chat.on_warning("Automatic approval review denied (risk: high): The planned action would transmit the full contents of a workspace source file (`core/src/codex.rs`) to `https://example.com`, which is an external and untrusted endpoint.");
chat.on_guardian_assessment(GuardianAssessmentEvent {
id: "guardian-1".into(),
target_item_id: Some("guardian-target-1".into()),
turn_id: "turn-1".into(),
status: GuardianAssessmentStatus::Denied,
risk_level: Some(GuardianRiskLevel::High),
user_authorization: Some(GuardianUserAuthorization::Low),
rationale: Some("Would exfiltrate local source code.".into()),
decision_source: Some(GuardianAssessmentDecisionSource::Agent),
action,
});
let width: u16 = 140;
@@ -140,23 +123,20 @@ async fn guardian_approved_exec_renders_approved_request() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.show_welcome_banner = false;
chat.handle_codex_event(Event {
id: "guardian-assessment".into(),
msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent {
id: "thread:child-thread:guardian-1".into(),
target_item_id: Some("guardian-approved-target".into()),
turn_id: "turn-1".into(),
status: GuardianAssessmentStatus::Approved,
risk_level: Some(GuardianRiskLevel::Low),
user_authorization: Some(GuardianUserAuthorization::High),
rationale: Some("Narrowly scoped to the requested file.".into()),
decision_source: Some(GuardianAssessmentDecisionSource::Agent),
action: GuardianAssessmentAction::Command {
source: GuardianCommandSource::Shell,
command: "rm -f /tmp/guardian-approved.sqlite".to_string(),
cwd: test_path_buf("/tmp").abs(),
},
}),
chat.on_guardian_assessment(GuardianAssessmentEvent {
id: "thread:child-thread:guardian-1".into(),
target_item_id: Some("guardian-approved-target".into()),
turn_id: "turn-1".into(),
status: GuardianAssessmentStatus::Approved,
risk_level: Some(GuardianRiskLevel::Low),
user_authorization: Some(GuardianUserAuthorization::High),
rationale: Some("Narrowly scoped to the requested file.".into()),
decision_source: Some(GuardianAssessmentDecisionSource::Agent),
action: GuardianAssessmentAction::Command {
source: GuardianCommandSource::Shell,
command: "rm -f /tmp/guardian-approved.sqlite".to_string(),
cwd: test_path_buf("/tmp").abs(),
},
});
let width: u16 = 120;
@@ -199,19 +179,16 @@ async fn guardian_approved_request_permissions_renders_request_summary() {
},
};
chat.handle_codex_event(Event {
id: "guardian-in-progress".into(),
msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent {
id: "guardian-request-permissions".into(),
target_item_id: None,
turn_id: "turn-1".into(),
status: GuardianAssessmentStatus::InProgress,
risk_level: None,
user_authorization: None,
rationale: None,
decision_source: None,
action: action.clone(),
}),
chat.on_guardian_assessment(GuardianAssessmentEvent {
id: "guardian-request-permissions".into(),
target_item_id: None,
turn_id: "turn-1".into(),
status: GuardianAssessmentStatus::InProgress,
risk_level: None,
user_authorization: None,
rationale: None,
decision_source: None,
action: action.clone(),
});
let status = chat
@@ -224,19 +201,16 @@ async fn guardian_approved_request_permissions_renders_request_summary() {
Some("permission request: Need write access for generated report assets.")
);
chat.handle_codex_event(Event {
id: "guardian-assessment".into(),
msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent {
id: "guardian-request-permissions".into(),
target_item_id: None,
turn_id: "turn-1".into(),
status: GuardianAssessmentStatus::Approved,
risk_level: Some(GuardianRiskLevel::Low),
user_authorization: Some(GuardianUserAuthorization::High),
rationale: Some("Request is scoped to report output.".into()),
decision_source: Some(GuardianAssessmentDecisionSource::Agent),
action,
}),
chat.on_guardian_assessment(GuardianAssessmentEvent {
id: "guardian-request-permissions".into(),
target_item_id: None,
turn_id: "turn-1".into(),
status: GuardianAssessmentStatus::Approved,
risk_level: Some(GuardianRiskLevel::Low),
user_authorization: Some(GuardianUserAuthorization::High),
rationale: Some("Request is scoped to report output.".into()),
decision_source: Some(GuardianAssessmentDecisionSource::Agent),
action,
});
let width: u16 = 110;
@@ -275,43 +249,30 @@ async fn guardian_timed_out_exec_renders_warning_and_timed_out_request() {
cwd: test_path_buf("/tmp").abs(),
};
chat.handle_codex_event(Event {
id: "guardian-in-progress".into(),
msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent {
id: "guardian-1".into(),
target_item_id: Some("guardian-target-1".into()),
turn_id: "turn-1".into(),
status: GuardianAssessmentStatus::InProgress,
risk_level: None,
user_authorization: None,
rationale: None,
decision_source: None,
action: action.clone(),
}),
chat.on_guardian_assessment(GuardianAssessmentEvent {
id: "guardian-1".into(),
target_item_id: Some("guardian-target-1".into()),
turn_id: "turn-1".into(),
status: GuardianAssessmentStatus::InProgress,
risk_level: None,
user_authorization: None,
rationale: None,
decision_source: None,
action: action.clone(),
});
chat.handle_codex_event(Event {
id: "guardian-warning".into(),
msg: EventMsg::GuardianWarning(WarningEvent {
message: "Automatic approval review timed out while evaluating the requested approval."
.into(),
}),
});
chat.handle_codex_event(Event {
id: "guardian-assessment".into(),
msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent {
id: "guardian-1".into(),
target_item_id: Some("guardian-target-1".into()),
turn_id: "turn-1".into(),
status: GuardianAssessmentStatus::TimedOut,
risk_level: None,
user_authorization: None,
rationale: Some(
"Automatic approval review timed out while evaluating the requested approval."
.into(),
),
decision_source: Some(GuardianAssessmentDecisionSource::Agent),
action,
}),
chat.on_warning("Automatic approval review timed out while evaluating the requested approval.");
chat.on_guardian_assessment(GuardianAssessmentEvent {
id: "guardian-1".into(),
target_item_id: Some("guardian-target-1".into()),
turn_id: "turn-1".into(),
status: GuardianAssessmentStatus::TimedOut,
risk_level: None,
user_authorization: None,
rationale: Some(
"Automatic approval review timed out while evaluating the requested approval.".into(),
),
decision_source: Some(GuardianAssessmentDecisionSource::Agent),
action,
});
let width: u16 = 140;
@@ -541,23 +502,20 @@ async fn guardian_parallel_reviews_render_aggregate_status_snapshot() {
("guardian-1", "rm -rf '/tmp/guardian target 1'"),
("guardian-2", "rm -rf '/tmp/guardian target 2'"),
] {
chat.handle_codex_event(Event {
id: format!("event-{id}"),
msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent {
id: id.to_string(),
target_item_id: Some(format!("{id}-target")),
turn_id: "turn-1".to_string(),
status: GuardianAssessmentStatus::InProgress,
risk_level: None,
user_authorization: None,
rationale: None,
decision_source: None,
action: GuardianAssessmentAction::Command {
source: GuardianCommandSource::Shell,
command: command.to_string(),
cwd: test_path_buf("/tmp").abs(),
},
}),
chat.on_guardian_assessment(GuardianAssessmentEvent {
id: id.to_string(),
target_item_id: Some(format!("{id}-target")),
turn_id: "turn-1".to_string(),
status: GuardianAssessmentStatus::InProgress,
risk_level: None,
user_authorization: None,
rationale: None,
decision_source: None,
action: GuardianAssessmentAction::Command {
source: GuardianCommandSource::Shell,
command: command.to_string(),
cwd: test_path_buf("/tmp").abs(),
},
});
}
@@ -573,59 +531,50 @@ async fn guardian_parallel_reviews_keep_remaining_review_visible_after_denial()
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.on_task_started();
chat.handle_codex_event(Event {
id: "event-guardian-1".into(),
msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent {
id: "guardian-1".to_string(),
target_item_id: Some("guardian-1-target".to_string()),
turn_id: "turn-1".to_string(),
status: GuardianAssessmentStatus::InProgress,
risk_level: None,
user_authorization: None,
rationale: None,
decision_source: None,
action: GuardianAssessmentAction::Command {
source: GuardianCommandSource::Shell,
command: "rm -rf '/tmp/guardian target 1'".to_string(),
cwd: test_path_buf("/tmp").abs(),
},
}),
chat.on_guardian_assessment(GuardianAssessmentEvent {
id: "guardian-1".to_string(),
target_item_id: Some("guardian-1-target".to_string()),
turn_id: "turn-1".to_string(),
status: GuardianAssessmentStatus::InProgress,
risk_level: None,
user_authorization: None,
rationale: None,
decision_source: None,
action: GuardianAssessmentAction::Command {
source: GuardianCommandSource::Shell,
command: "rm -rf '/tmp/guardian target 1'".to_string(),
cwd: test_path_buf("/tmp").abs(),
},
});
chat.handle_codex_event(Event {
id: "event-guardian-2".into(),
msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent {
id: "guardian-2".to_string(),
target_item_id: Some("guardian-2-target".to_string()),
turn_id: "turn-1".to_string(),
status: GuardianAssessmentStatus::InProgress,
risk_level: None,
user_authorization: None,
rationale: None,
decision_source: None,
action: GuardianAssessmentAction::Command {
source: GuardianCommandSource::Shell,
command: "rm -rf '/tmp/guardian target 2'".to_string(),
cwd: test_path_buf("/tmp").abs(),
},
}),
chat.on_guardian_assessment(GuardianAssessmentEvent {
id: "guardian-2".to_string(),
target_item_id: Some("guardian-2-target".to_string()),
turn_id: "turn-1".to_string(),
status: GuardianAssessmentStatus::InProgress,
risk_level: None,
user_authorization: None,
rationale: None,
decision_source: None,
action: GuardianAssessmentAction::Command {
source: GuardianCommandSource::Shell,
command: "rm -rf '/tmp/guardian target 2'".to_string(),
cwd: test_path_buf("/tmp").abs(),
},
});
chat.handle_codex_event(Event {
id: "event-guardian-1-denied".into(),
msg: EventMsg::GuardianAssessment(GuardianAssessmentEvent {
id: "guardian-1".to_string(),
target_item_id: Some("guardian-1-target".to_string()),
turn_id: "turn-1".to_string(),
status: GuardianAssessmentStatus::Denied,
risk_level: Some(GuardianRiskLevel::High),
user_authorization: Some(GuardianUserAuthorization::Low),
rationale: Some("Would delete important data.".to_string()),
decision_source: Some(GuardianAssessmentDecisionSource::Agent),
action: GuardianAssessmentAction::Command {
source: GuardianCommandSource::Shell,
command: "rm -rf '/tmp/guardian target 1'".to_string(),
cwd: test_path_buf("/tmp").abs(),
},
}),
chat.on_guardian_assessment(GuardianAssessmentEvent {
id: "guardian-1".to_string(),
target_item_id: Some("guardian-1-target".to_string()),
turn_id: "turn-1".to_string(),
status: GuardianAssessmentStatus::Denied,
risk_level: Some(GuardianRiskLevel::High),
user_authorization: Some(GuardianUserAuthorization::Low),
rationale: Some("Would delete important data.".to_string()),
decision_source: Some(GuardianAssessmentDecisionSource::Agent),
action: GuardianAssessmentAction::Command {
source: GuardianCommandSource::Shell,
command: "rm -rf '/tmp/guardian target 1'".to_string(),
cwd: test_path_buf("/tmp").abs(),
},
});
assert_eq!(chat.current_status.header, "Reviewing approval request");
+705 -151
View File
@@ -99,8 +99,8 @@ pub(super) fn snapshot(percent: f64) -> RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: Some(RateLimitWindow {
used_percent: percent,
window_minutes: Some(60),
used_percent: percent.round() as i32,
window_duration_mins: Some(60),
resets_at: None,
}),
secondary: None,
@@ -122,7 +122,7 @@ pub(super) fn test_session_telemetry(config: &Config, model: &str) -> SessionTel
"test_originator".to_string(),
/*log_user_prompts*/ false,
"test".to_string(),
SessionSource::Cli,
crate::test_support::session_source_cli(),
)
}
@@ -311,7 +311,7 @@ pub(super) async fn make_chatwidget_manual(
goal_status_active_turn_started_at: None,
external_editor_state: ExternalEditorState::Closed,
realtime_conversation: RealtimeConversationUiState::default(),
last_rendered_user_message_event: None,
last_rendered_user_message_display: None,
last_non_retry_error: None,
};
widget.set_model(&resolved_model);
@@ -483,35 +483,453 @@ pub(super) fn make_token_info(total_tokens: i64, context_window: i64) -> TokenUs
}
}
fn thread_id(chat: &ChatWidget) -> String {
chat.thread_id.map(|id| id.to_string()).unwrap_or_default()
}
fn token_usage_breakdown(usage: TokenUsage) -> codex_app_server_protocol::TokenUsageBreakdown {
codex_app_server_protocol::TokenUsageBreakdown {
total_tokens: usage.total_tokens,
input_tokens: usage.input_tokens,
cached_input_tokens: usage.cached_input_tokens,
output_tokens: usage.output_tokens,
reasoning_output_tokens: usage.reasoning_output_tokens,
}
}
pub(super) fn handle_token_count(chat: &mut ChatWidget, info: Option<TokenUsageInfo>) {
match info {
Some(info) => {
chat.handle_server_notification(
ServerNotification::ThreadTokenUsageUpdated(
codex_app_server_protocol::ThreadTokenUsageUpdatedNotification {
thread_id: thread_id(chat),
turn_id: chat
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
token_usage: codex_app_server_protocol::ThreadTokenUsage {
total: token_usage_breakdown(info.total_token_usage),
last: token_usage_breakdown(info.last_token_usage),
model_context_window: info.model_context_window,
},
},
),
/*replay_kind*/ None,
);
}
None => chat.set_token_info(/*info*/ None),
}
}
pub(super) fn handle_error(
chat: &mut ChatWidget,
message: impl Into<String>,
codex_error_info: Option<CodexErrorInfo>,
) {
chat.handle_server_notification(
ServerNotification::Error(ErrorNotification {
error: AppServerTurnError {
message: message.into(),
codex_error_info,
additional_details: None,
},
will_retry: false,
thread_id: thread_id(chat),
turn_id: chat
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
}),
/*replay_kind*/ None,
);
}
pub(super) fn handle_stream_error(
chat: &mut ChatWidget,
message: impl Into<String>,
additional_details: Option<String>,
) {
handle_stream_error_with_replay(chat, message, additional_details, /*replay_kind*/ None);
}
pub(super) fn handle_stream_error_with_replay(
chat: &mut ChatWidget,
message: impl Into<String>,
additional_details: Option<String>,
replay_kind: Option<ReplayKind>,
) {
chat.handle_server_notification(
ServerNotification::Error(ErrorNotification {
error: AppServerTurnError {
message: message.into(),
codex_error_info: None,
additional_details,
},
will_retry: true,
thread_id: thread_id(chat),
turn_id: chat
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
}),
replay_kind,
);
}
pub(super) fn handle_warning(chat: &mut ChatWidget, message: impl Into<String>) {
chat.handle_server_notification(
ServerNotification::Warning(WarningNotification {
thread_id: Some(thread_id(chat)),
message: message.into(),
}),
/*replay_kind*/ None,
);
}
pub(super) fn handle_model_verification(
chat: &mut ChatWidget,
verifications: Vec<AppServerModelVerification>,
) {
chat.handle_server_notification(
ServerNotification::ModelVerification(ModelVerificationNotification {
thread_id: thread_id(chat),
turn_id: chat
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
verifications,
}),
/*replay_kind*/ None,
);
}
pub(super) fn handle_agent_message_delta(chat: &mut ChatWidget, delta: impl Into<String>) {
chat.handle_server_notification(
ServerNotification::AgentMessageDelta(
codex_app_server_protocol::AgentMessageDeltaNotification {
thread_id: thread_id(chat),
turn_id: chat
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
item_id: "msg-1".to_string(),
delta: delta.into(),
},
),
/*replay_kind*/ None,
);
}
pub(super) fn handle_agent_reasoning_delta(chat: &mut ChatWidget, delta: impl Into<String>) {
chat.handle_server_notification(
ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification {
thread_id: thread_id(chat),
turn_id: chat
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
item_id: "reasoning-1".to_string(),
delta: delta.into(),
summary_index: 0,
}),
/*replay_kind*/ None,
);
}
pub(super) fn handle_agent_reasoning_final(chat: &mut ChatWidget) {
chat.handle_server_notification(
ServerNotification::ItemCompleted(ItemCompletedNotification {
thread_id: thread_id(chat),
turn_id: chat
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
item: AppServerThreadItem::Reasoning {
id: "reasoning-1".to_string(),
summary: Vec::new(),
content: Vec::new(),
},
}),
/*replay_kind*/ None,
);
}
pub(super) fn handle_entered_review_mode(chat: &mut ChatWidget, review: impl Into<String>) {
chat.handle_server_notification(
ServerNotification::ItemStarted(ItemStartedNotification {
thread_id: thread_id(chat),
turn_id: chat
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
item: AppServerThreadItem::EnteredReviewMode {
id: "review-start".to_string(),
review: review.into(),
},
}),
/*replay_kind*/ None,
);
}
pub(super) fn replay_entered_review_mode(chat: &mut ChatWidget, review: impl Into<String>) {
chat.replay_thread_item(
AppServerThreadItem::EnteredReviewMode {
id: "review-start".to_string(),
review: review.into(),
},
"turn-1".to_string(),
ReplayKind::ThreadSnapshot,
);
}
pub(super) fn handle_exited_review_mode(chat: &mut ChatWidget) {
chat.handle_server_notification(
ServerNotification::ItemCompleted(ItemCompletedNotification {
thread_id: thread_id(chat),
turn_id: chat
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
item: AppServerThreadItem::ExitedReviewMode {
id: "review-end".to_string(),
review: String::new(),
},
}),
/*replay_kind*/ None,
);
}
pub(super) fn handle_exec_approval_request(
chat: &mut ChatWidget,
id: impl Into<String>,
event: ExecApprovalRequestEvent,
) {
chat.on_exec_approval_request(id.into(), event);
}
pub(super) fn handle_apply_patch_approval_request(
chat: &mut ChatWidget,
id: impl Into<String>,
event: ApplyPatchApprovalRequestEvent,
) {
chat.on_apply_patch_approval_request(id.into(), event);
}
fn file_update_changes_from_tui(changes: HashMap<PathBuf, FileChange>) -> Vec<FileUpdateChange> {
changes
.into_iter()
.map(|(path, change)| {
let (kind, diff) = match change {
FileChange::Add { content } => (PatchChangeKind::Add, content),
FileChange::Delete { content } => (PatchChangeKind::Delete, content),
FileChange::Update {
unified_diff,
move_path,
} => (PatchChangeKind::Update { move_path }, unified_diff),
};
FileUpdateChange {
path: path.display().to_string(),
kind,
diff,
}
})
.collect()
}
pub(super) fn handle_patch_apply_begin(
chat: &mut ChatWidget,
call_id: impl Into<String>,
turn_id: impl Into<String>,
changes: HashMap<PathBuf, FileChange>,
) {
chat.handle_server_notification(
ServerNotification::ItemStarted(ItemStartedNotification {
thread_id: thread_id(chat),
turn_id: turn_id.into(),
item: AppServerThreadItem::FileChange {
id: call_id.into(),
changes: file_update_changes_from_tui(changes),
status: AppServerPatchApplyStatus::InProgress,
},
}),
/*replay_kind*/ None,
);
}
pub(super) fn handle_patch_apply_end(
chat: &mut ChatWidget,
call_id: impl Into<String>,
turn_id: impl Into<String>,
changes: HashMap<PathBuf, FileChange>,
status: AppServerPatchApplyStatus,
) {
chat.handle_server_notification(
ServerNotification::ItemCompleted(ItemCompletedNotification {
thread_id: thread_id(chat),
turn_id: turn_id.into(),
item: AppServerThreadItem::FileChange {
id: call_id.into(),
changes: file_update_changes_from_tui(changes),
status,
},
}),
/*replay_kind*/ None,
);
}
pub(super) fn handle_view_image_tool_call(
chat: &mut ChatWidget,
call_id: impl Into<String>,
path: AbsolutePathBuf,
) {
chat.handle_server_notification(
ServerNotification::ItemCompleted(ItemCompletedNotification {
thread_id: thread_id(chat),
turn_id: "turn-1".to_string(),
item: AppServerThreadItem::ImageView {
id: call_id.into(),
path,
},
}),
/*replay_kind*/ None,
);
}
pub(super) fn handle_image_generation_end(
chat: &mut ChatWidget,
call_id: impl Into<String>,
revised_prompt: Option<String>,
saved_path: Option<AbsolutePathBuf>,
) {
chat.handle_server_notification(
ServerNotification::ItemCompleted(ItemCompletedNotification {
thread_id: thread_id(chat),
turn_id: "turn-1".to_string(),
item: AppServerThreadItem::ImageGeneration {
id: call_id.into(),
status: "completed".to_string(),
revised_prompt,
result: String::new(),
saved_path,
},
}),
/*replay_kind*/ None,
);
}
pub(super) fn replay_user_message_inputs(
chat: &mut ChatWidget,
item_id: &str,
content: Vec<AppServerUserInput>,
replay_kind: ReplayKind,
) {
chat.replay_thread_item(
AppServerThreadItem::UserMessage {
id: item_id.to_string(),
content,
},
"turn-1".to_string(),
replay_kind,
);
}
pub(super) fn replay_user_message_text(
chat: &mut ChatWidget,
item_id: &str,
text: impl Into<String>,
replay_kind: ReplayKind,
) {
replay_user_message_inputs(
chat,
item_id,
vec![AppServerUserInput::Text {
text: text.into(),
text_elements: Vec::new(),
}],
replay_kind,
);
}
pub(super) fn replay_agent_message(
chat: &mut ChatWidget,
item_id: &str,
text: impl Into<String>,
replay_kind: ReplayKind,
) {
chat.replay_thread_item(
AppServerThreadItem::AgentMessage {
id: item_id.to_string(),
text: text.into(),
phase: Some(MessagePhase::FinalAnswer),
memory_citation: None,
},
"turn-1".to_string(),
replay_kind,
);
}
pub(super) fn replay_turn_started(chat: &mut ChatWidget, replay_kind: ReplayKind) {
chat.handle_server_notification(
ServerNotification::TurnStarted(TurnStartedNotification {
thread_id: thread_id(chat),
turn: app_server_turn(
"turn-1",
AppServerTurnStatus::InProgress,
/*duration_ms*/ None,
/*error*/ None,
),
}),
Some(replay_kind),
);
}
pub(super) fn replay_agent_message_delta(
chat: &mut ChatWidget,
delta: impl Into<String>,
replay_kind: ReplayKind,
) {
chat.handle_server_notification(
ServerNotification::AgentMessageDelta(
codex_app_server_protocol::AgentMessageDeltaNotification {
thread_id: thread_id(chat),
turn_id: "turn-1".to_string(),
item_id: "msg-1".to_string(),
delta: delta.into(),
},
),
Some(replay_kind),
);
}
// --- Small helpers to tersely drive exec begin/end and snapshot active cell ---
pub(super) fn begin_exec_with_source(
chat: &mut ChatWidget,
call_id: &str,
raw_cmd: &str,
source: ExecCommandSource,
) -> ExecCommandBeginEvent {
) -> AppServerThreadItem {
// Build the full command vec and parse it using core's parser,
// then convert to protocol variants for the event payload.
let command = vec!["bash".to_string(), "-lc".to_string(), raw_cmd.to_string()];
let parsed_cmd: Vec<ParsedCommand> =
codex_shell_command::parse_command::parse_command(&command);
let cwd = AbsolutePathBuf::current_dir().expect("current dir");
let interaction_input = None;
let event = ExecCommandBeginEvent {
call_id: call_id.to_string(),
process_id: None,
turn_id: "turn-1".to_string(),
command,
cwd,
parsed_cmd,
source,
interaction_input,
};
chat.handle_codex_event(Event {
let command_actions = codex_shell_command::parse_command::parse_command(&command)
.into_iter()
.map(|parsed| AppServerCommandAction::from_core_with_cwd(parsed, &chat.config.cwd))
.collect();
let item = AppServerThreadItem::CommandExecution {
id: call_id.to_string(),
msg: EventMsg::ExecCommandBegin(event.clone()),
});
event
command: codex_shell_command::parse_command::shlex_join(&command),
cwd: chat.config.cwd.clone(),
process_id: None,
source,
status: AppServerCommandExecutionStatus::InProgress,
command_actions,
aggregated_output: None,
exit_code: None,
duration_ms: None,
};
handle_exec_begin(chat, item.clone());
item
}
pub(super) fn begin_unified_exec_startup(
@@ -519,24 +937,36 @@ pub(super) fn begin_unified_exec_startup(
call_id: &str,
process_id: &str,
raw_cmd: &str,
) -> ExecCommandBeginEvent {
) -> AppServerThreadItem {
let command = vec!["bash".to_string(), "-lc".to_string(), raw_cmd.to_string()];
let cwd = AbsolutePathBuf::current_dir().expect("current dir");
let event = ExecCommandBeginEvent {
call_id: call_id.to_string(),
process_id: Some(process_id.to_string()),
turn_id: "turn-1".to_string(),
command,
cwd,
parsed_cmd: Vec::new(),
source: ExecCommandSource::UnifiedExecStartup,
interaction_input: None,
};
chat.handle_codex_event(Event {
let item = AppServerThreadItem::CommandExecution {
id: call_id.to_string(),
msg: EventMsg::ExecCommandBegin(event.clone()),
});
event
command: codex_shell_command::parse_command::shlex_join(&command),
cwd: chat.config.cwd.clone(),
process_id: Some(process_id.to_string()),
source: ExecCommandSource::UnifiedExecStartup,
status: AppServerCommandExecutionStatus::InProgress,
command_actions: Vec::new(),
aggregated_output: None,
exit_code: None,
duration_ms: None,
};
handle_exec_begin(chat, item.clone());
item
}
pub(super) fn handle_exec_begin(chat: &mut ChatWidget, item: AppServerThreadItem) {
chat.handle_server_notification(
ServerNotification::ItemStarted(ItemStartedNotification {
thread_id: thread_id(chat),
turn_id: chat
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
item,
}),
/*replay_kind*/ None,
);
}
pub(super) fn terminal_interaction(
@@ -545,14 +975,21 @@ pub(super) fn terminal_interaction(
process_id: &str,
stdin: &str,
) {
chat.handle_codex_event(Event {
id: call_id.to_string(),
msg: EventMsg::TerminalInteraction(TerminalInteractionEvent {
call_id: call_id.to_string(),
process_id: process_id.to_string(),
stdin: stdin.to_string(),
}),
});
chat.handle_server_notification(
ServerNotification::TerminalInteraction(
codex_app_server_protocol::TerminalInteractionNotification {
thread_id: thread_id(chat),
turn_id: chat
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
item_id: call_id.to_string(),
process_id: process_id.to_string(),
stdin: stdin.to_string(),
},
),
/*replay_kind*/ None,
);
}
pub(super) fn complete_assistant_message(
@@ -561,21 +998,19 @@ pub(super) fn complete_assistant_message(
text: &str,
phase: Option<MessagePhase>,
) {
chat.handle_codex_event(Event {
id: format!("raw-{item_id}"),
msg: EventMsg::ItemCompleted(ItemCompletedEvent {
thread_id: ThreadId::new(),
chat.handle_server_notification(
ServerNotification::ItemCompleted(ItemCompletedNotification {
thread_id: chat.thread_id.map(|id| id.to_string()).unwrap_or_default(),
turn_id: "turn-1".to_string(),
item: TurnItem::AgentMessage(AgentMessageItem {
item: AppServerThreadItem::AgentMessage {
id: item_id.to_string(),
content: vec![AgentMessageContent::Text {
text: text.to_string(),
}],
text: text.to_string(),
phase,
memory_citation: None,
}),
},
}),
});
/*replay_kind*/ None,
);
}
pub(super) fn pending_steer(text: &str) -> PendingSteer {
@@ -605,30 +1040,101 @@ pub(super) fn complete_user_message_for_inputs(
item_id: &str,
content: Vec<UserInput>,
) {
chat.handle_codex_event(Event {
id: format!("raw-{item_id}"),
msg: EventMsg::ItemCompleted(ItemCompletedEvent {
thread_id: ThreadId::new(),
chat.handle_server_notification(
ServerNotification::ItemCompleted(ItemCompletedNotification {
thread_id: chat.thread_id.map(|id| id.to_string()).unwrap_or_default(),
turn_id: "turn-1".to_string(),
item: TurnItem::UserMessage(UserMessageItem {
item: AppServerThreadItem::UserMessage {
id: item_id.to_string(),
content,
}),
},
}),
});
/*replay_kind*/ None,
);
}
pub(super) fn app_server_turn(
turn_id: &str,
status: AppServerTurnStatus,
duration_ms: Option<i64>,
error: Option<AppServerTurnError>,
) -> AppServerTurn {
AppServerTurn {
id: turn_id.to_string(),
items: Vec::new(),
status,
error,
started_at: None,
completed_at: None,
duration_ms,
}
}
pub(super) fn handle_turn_started(chat: &mut ChatWidget, turn_id: &str) {
chat.handle_server_notification(
ServerNotification::TurnStarted(TurnStartedNotification {
thread_id: chat.thread_id.map(|id| id.to_string()).unwrap_or_default(),
turn: app_server_turn(
turn_id,
AppServerTurnStatus::InProgress,
/*duration_ms*/ None,
/*error*/ None,
),
}),
/*replay_kind*/ None,
);
}
pub(super) fn handle_turn_completed(
chat: &mut ChatWidget,
turn_id: &str,
duration_ms: Option<i64>,
) {
chat.handle_server_notification(
ServerNotification::TurnCompleted(TurnCompletedNotification {
thread_id: chat.thread_id.map(|id| id.to_string()).unwrap_or_default(),
turn: app_server_turn(
turn_id,
AppServerTurnStatus::Completed,
duration_ms,
/*error*/ None,
),
}),
/*replay_kind*/ None,
);
}
pub(super) fn handle_turn_interrupted(chat: &mut ChatWidget, turn_id: &str) {
chat.handle_server_notification(
ServerNotification::TurnCompleted(TurnCompletedNotification {
thread_id: chat.thread_id.map(|id| id.to_string()).unwrap_or_default(),
turn: app_server_turn(
turn_id,
AppServerTurnStatus::Interrupted,
/*duration_ms*/ None,
/*error*/ None,
),
}),
/*replay_kind*/ None,
);
}
pub(super) fn handle_budget_limited_turn(chat: &mut ChatWidget, turn_id: &str) {
chat.budget_limited_turn_ids.insert(turn_id.to_string());
handle_turn_interrupted(chat, turn_id);
}
pub(super) fn begin_exec(
chat: &mut ChatWidget,
call_id: &str,
raw_cmd: &str,
) -> ExecCommandBeginEvent {
) -> AppServerThreadItem {
begin_exec_with_source(chat, call_id, raw_cmd, ExecCommandSource::Agent)
}
pub(super) fn end_exec(
chat: &mut ChatWidget,
begin_event: ExecCommandBeginEvent,
begin_item: AppServerThreadItem,
stdout: &str,
stderr: &str,
exit_code: i32,
@@ -638,40 +1144,51 @@ pub(super) fn end_exec(
} else {
format!("{stdout}{stderr}")
};
let ExecCommandBeginEvent {
call_id,
turn_id,
let AppServerThreadItem::CommandExecution {
id,
command,
cwd,
parsed_cmd,
source,
interaction_input,
process_id,
} = begin_event;
chat.handle_codex_event(Event {
id: call_id.clone(),
msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id,
process_id,
turn_id,
source,
command_actions,
..
} = begin_item
else {
panic!("expected command execution item");
};
handle_exec_end(
chat,
AppServerThreadItem::CommandExecution {
id,
command,
cwd,
parsed_cmd,
process_id,
source,
interaction_input,
stdout: stdout.to_string(),
stderr: stderr.to_string(),
aggregated_output: aggregated.clone(),
exit_code,
duration: std::time::Duration::from_millis(5),
formatted_output: aggregated,
status: if exit_code == 0 {
CoreExecCommandStatus::Completed
AppServerCommandExecutionStatus::Completed
} else {
CoreExecCommandStatus::Failed
AppServerCommandExecutionStatus::Failed
},
command_actions,
aggregated_output: (!aggregated.is_empty()).then_some(aggregated),
exit_code: Some(exit_code),
duration_ms: Some(5),
},
);
}
pub(super) fn handle_exec_end(chat: &mut ChatWidget, item: AppServerThreadItem) {
chat.handle_server_notification(
ServerNotification::ItemCompleted(ItemCompletedNotification {
thread_id: thread_id(chat),
turn_id: chat
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
item,
}),
});
/*replay_kind*/ None,
);
}
pub(super) fn active_blob(chat: &ChatWidget) -> String {
@@ -1001,36 +1518,85 @@ pub(super) fn type_plugins_search_query(chat: &mut ChatWidget, query: &str) {
}
}
pub(super) fn handle_hook_started(chat: &mut ChatWidget, run: AppServerHookRunSummary) {
chat.handle_server_notification(
ServerNotification::HookStarted(AppServerHookStartedNotification {
thread_id: thread_id(chat),
turn_id: None,
run,
}),
/*replay_kind*/ None,
);
}
pub(super) fn handle_hook_completed(chat: &mut ChatWidget, run: AppServerHookRunSummary) {
chat.handle_server_notification(
ServerNotification::HookCompleted(AppServerHookCompletedNotification {
thread_id: thread_id(chat),
turn_id: None,
run,
}),
/*replay_kind*/ None,
);
}
pub(super) fn hook_run(
run_id: &str,
event_name: codex_app_server_protocol::HookEventName,
status: codex_app_server_protocol::HookRunStatus,
status_message: &str,
entries: Vec<codex_app_server_protocol::HookOutputEntry>,
) -> codex_app_server_protocol::HookRunSummary {
codex_app_server_protocol::HookRunSummary {
id: run_id.to_string(),
event_name,
handler_type: codex_app_server_protocol::HookHandlerType::Command,
execution_mode: codex_app_server_protocol::HookExecutionMode::Sync,
scope: codex_app_server_protocol::HookScope::Turn,
source_path: PathBuf::from(test_path_display("/tmp/hooks.json")).abs(),
source: codex_app_server_protocol::HookSource::User,
display_order: 0,
status,
status_message: Some(status_message.to_string()),
started_at: 1,
completed_at: matches!(
status,
codex_app_server_protocol::HookRunStatus::Completed
| codex_app_server_protocol::HookRunStatus::Failed
| codex_app_server_protocol::HookRunStatus::Blocked
| codex_app_server_protocol::HookRunStatus::Stopped
)
.then_some(11),
duration_ms: matches!(
status,
codex_app_server_protocol::HookRunStatus::Completed
| codex_app_server_protocol::HookRunStatus::Failed
| codex_app_server_protocol::HookRunStatus::Blocked
| codex_app_server_protocol::HookRunStatus::Stopped
)
.then_some(10),
entries,
}
}
pub(super) async fn assert_hook_events_snapshot(
event_name: codex_protocol::protocol::HookEventName,
event_name: codex_app_server_protocol::HookEventName,
run_id: &str,
status_message: &str,
snapshot_name: &str,
) {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_codex_event(Event {
id: "hook-1".into(),
msg: EventMsg::HookStarted(codex_protocol::protocol::HookStartedEvent {
turn_id: None,
run: codex_protocol::protocol::HookRunSummary {
id: run_id.to_string(),
event_name,
handler_type: codex_protocol::protocol::HookHandlerType::Command,
execution_mode: codex_protocol::protocol::HookExecutionMode::Sync,
scope: codex_protocol::protocol::HookScope::Turn,
source_path: PathBuf::from(test_path_display("/tmp/hooks.json")).abs(),
source: codex_protocol::protocol::HookSource::User,
display_order: 0,
status: codex_protocol::protocol::HookRunStatus::Running,
status_message: Some(status_message.to_string()),
started_at: 1,
completed_at: None,
duration_ms: None,
entries: vec![],
},
}),
});
handle_hook_started(
&mut chat,
hook_run(
run_id,
event_name,
codex_app_server_protocol::HookRunStatus::Running,
status_message,
Vec::new(),
),
);
assert!(
drain_insert_history(&mut rx).is_empty(),
"hook start should update the live hook cell instead of writing history"
@@ -1044,37 +1610,25 @@ pub(super) async fn assert_hook_events_snapshot(
"hook start should render in the live hook cell"
);
chat.handle_codex_event(Event {
id: "hook-1".into(),
msg: EventMsg::HookCompleted(codex_protocol::protocol::HookCompletedEvent {
turn_id: None,
run: codex_protocol::protocol::HookRunSummary {
id: run_id.to_string(),
event_name,
handler_type: codex_protocol::protocol::HookHandlerType::Command,
execution_mode: codex_protocol::protocol::HookExecutionMode::Sync,
scope: codex_protocol::protocol::HookScope::Turn,
source_path: PathBuf::from(test_path_display("/tmp/hooks.json")).abs(),
source: codex_protocol::protocol::HookSource::User,
display_order: 0,
status: codex_protocol::protocol::HookRunStatus::Completed,
status_message: Some(status_message.to_string()),
started_at: 1,
completed_at: Some(11),
duration_ms: Some(10),
entries: vec![
codex_protocol::protocol::HookOutputEntry {
kind: codex_protocol::protocol::HookOutputEntryKind::Warning,
text: "Heads up from the hook".to_string(),
},
codex_protocol::protocol::HookOutputEntry {
kind: codex_protocol::protocol::HookOutputEntryKind::Context,
text: "Remember the startup checklist.".to_string(),
},
],
},
}),
});
handle_hook_completed(
&mut chat,
hook_run(
run_id,
event_name,
codex_app_server_protocol::HookRunStatus::Completed,
status_message,
vec![
codex_app_server_protocol::HookOutputEntry {
kind: codex_app_server_protocol::HookOutputEntryKind::Warning,
text: "Heads up from the hook".to_string(),
},
codex_app_server_protocol::HookOutputEntry {
kind: codex_app_server_protocol::HookOutputEntryKind::Context,
text: "Remember the startup checklist.".to_string(),
},
],
),
);
let cells = drain_insert_history(&mut rx);
let combined = cells
@@ -1084,13 +1638,13 @@ pub(super) async fn assert_hook_events_snapshot(
assert_chatwidget_snapshot!(snapshot_name, combined);
}
fn hook_event_label(event_name: codex_protocol::protocol::HookEventName) -> &'static str {
fn hook_event_label(event_name: codex_app_server_protocol::HookEventName) -> &'static str {
match event_name {
codex_protocol::protocol::HookEventName::PreToolUse => "PreToolUse",
codex_protocol::protocol::HookEventName::PermissionRequest => "PermissionRequest",
codex_protocol::protocol::HookEventName::PostToolUse => "PostToolUse",
codex_protocol::protocol::HookEventName::SessionStart => "SessionStart",
codex_protocol::protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit",
codex_protocol::protocol::HookEventName::Stop => "Stop",
codex_app_server_protocol::HookEventName::PreToolUse => "PreToolUse",
codex_app_server_protocol::HookEventName::PermissionRequest => "PermissionRequest",
codex_app_server_protocol::HookEventName::PostToolUse => "PostToolUse",
codex_app_server_protocol::HookEventName::SessionStart => "SessionStart",
codex_app_server_protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit",
codex_app_server_protocol::HookEventName::Stop => "Stop",
}
}
@@ -1,11 +1,13 @@
use super::*;
use codex_protocol::protocol::FileSystemAccessMode;
use codex_protocol::protocol::FileSystemPath;
use codex_protocol::protocol::FileSystemSandboxEntry;
use codex_protocol::protocol::FileSystemSandboxKind;
use codex_protocol::protocol::FileSystemSandboxPolicy;
use codex_protocol::protocol::FileSystemSpecialPath;
use codex_protocol::protocol::NetworkSandboxPolicy;
use codex_app_server_protocol::FileSystemAccessMode;
use codex_app_server_protocol::FileSystemPath;
use codex_app_server_protocol::FileSystemSandboxEntry;
use codex_app_server_protocol::FileSystemSpecialPath;
use codex_app_server_protocol::NetworkAccess;
use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile;
use codex_app_server_protocol::PermissionProfileFileSystemPermissions;
use codex_app_server_protocol::PermissionProfileNetworkPermissions;
use codex_app_server_protocol::SandboxPolicy;
use pretty_assertions::assert_eq;
#[tokio::test]
@@ -14,42 +16,40 @@ async fn resumed_initial_messages_render_history() {
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = codex_protocol::protocol::SessionConfiguredEvent {
session_id: conversation_id,
let configured = crate::session_state::ThreadSessionState {
thread_id: conversation_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: codex_protocol::models::PermissionProfile::read_only(),
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
initial_messages: Some(vec![
EventMsg::UserMessage(UserMessageEvent {
message: "hello from user".to_string(),
images: None,
text_elements: Vec::new(),
local_images: Vec::new(),
}),
EventMsg::AgentMessage(AgentMessageEvent {
message: "assistant reply".to_string(),
phase: None,
memory_citation: None,
}),
]),
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
chat.handle_codex_event(Event {
id: "initial".into(),
msg: EventMsg::SessionConfigured(configured),
});
chat.handle_thread_session(configured);
replay_user_message_text(
&mut chat,
"user-1",
"hello from user",
ReplayKind::ResumeInitialMessages,
);
replay_agent_message(
&mut chat,
"assistant-1",
"assistant reply",
ReplayKind::ResumeInitialMessages,
);
let cells = drain_insert_history(&mut rx);
let mut merged_lines = Vec::new();
@@ -73,47 +73,6 @@ async fn resumed_initial_messages_render_history() {
);
}
#[tokio::test]
async fn thread_snapshot_replay_does_not_duplicate_agent_message_history() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_codex_event_replay(Event {
id: "turn-1".into(),
msg: EventMsg::ItemCompleted(ItemCompletedEvent {
thread_id: ThreadId::new(),
turn_id: "turn-1".to_string(),
item: TurnItem::AgentMessage(AgentMessageItem {
id: "msg-1".to_string(),
content: vec![AgentMessageContent::Text {
text: "assistant reply".to_string(),
}],
phase: None,
memory_citation: None,
}),
}),
});
chat.handle_codex_event_replay(Event {
id: "turn-1".into(),
msg: EventMsg::AgentMessage(AgentMessageEvent {
message: "assistant reply".to_string(),
phase: None,
memory_citation: None,
}),
});
let cells = drain_insert_history(&mut rx);
assert_eq!(
cells.len(),
1,
"expected replayed assistant message to render once"
);
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains("assistant reply"),
"expected replayed assistant message, got {rendered:?}"
);
}
#[tokio::test]
async fn replayed_user_message_preserves_text_elements_and_local_images() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -128,35 +87,42 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() {
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = codex_protocol::protocol::SessionConfiguredEvent {
session_id: conversation_id,
let configured = crate::session_state::ThreadSessionState {
thread_id: conversation_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: codex_protocol::models::PermissionProfile::read_only(),
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
initial_messages: Some(vec![EventMsg::UserMessage(UserMessageEvent {
message: message.clone(),
images: None,
text_elements: text_elements.clone(),
local_images: local_images.clone(),
})]),
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
chat.handle_codex_event(Event {
id: "initial".into(),
msg: EventMsg::SessionConfigured(configured),
});
chat.handle_thread_session(configured);
replay_user_message_inputs(
&mut chat,
"user-1",
vec![
AppServerUserInput::Text {
text: message.clone(),
text_elements: text_elements.clone().into_iter().map(Into::into).collect(),
},
AppServerUserInput::LocalImage {
path: local_images[0].clone(),
},
],
ReplayKind::ResumeInitialMessages,
);
let mut user_cell = None;
while let Ok(ev) = rx.try_recv() {
@@ -190,35 +156,42 @@ async fn replayed_user_message_preserves_remote_image_urls() {
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = codex_protocol::protocol::SessionConfiguredEvent {
session_id: conversation_id,
let configured = crate::session_state::ThreadSessionState {
thread_id: conversation_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: codex_protocol::models::PermissionProfile::read_only(),
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
initial_messages: Some(vec![EventMsg::UserMessage(UserMessageEvent {
message: message.clone(),
images: Some(remote_image_urls.clone()),
text_elements: Vec::new(),
local_images: Vec::new(),
})]),
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
chat.handle_codex_event(Event {
id: "initial".into(),
msg: EventMsg::SessionConfigured(configured),
});
chat.handle_thread_session(configured);
replay_user_message_inputs(
&mut chat,
"user-1",
vec![
AppServerUserInput::Text {
text: message.clone(),
text_elements: Vec::new(),
},
AppServerUserInput::Image {
url: remote_image_urls[0].clone(),
},
],
ReplayKind::ResumeInitialMessages,
);
let mut user_cell = None;
while let Ok(ev) = rx.try_recv() {
@@ -248,7 +221,7 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() {
chat.config
.permissions
.approval_policy
.set(AskForApproval::OnRequest)
.set(AskForApproval::OnRequest.to_core())
.expect("set approval policy");
chat.config
.permissions
@@ -257,64 +230,64 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() {
chat.config.cwd = test_path_buf("/home/user/main").abs();
let expected_cwd = test_path_buf("/home/user/sub-agent").abs();
let expected_file_system_policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
let expected_app_server_permission_profile = AppServerPermissionProfile::Managed {
network: PermissionProfileNetworkPermissions { enabled: false },
file_system: PermissionProfileFileSystemPermissions::Restricted {
entries: vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: "**/.secret".to_string(),
},
access: FileSystemAccessMode::None,
},
],
glob_scan_max_depth: None,
},
FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: "**/.secret".to_string(),
},
access: FileSystemAccessMode::None,
},
]);
let expected_permission_profile =
codex_protocol::models::PermissionProfile::from_runtime_permissions(
&expected_file_system_policy,
NetworkSandboxPolicy::Restricted,
);
let expected_sandbox = expected_permission_profile
};
let expected_permission_profile: PermissionProfile =
expected_app_server_permission_profile.clone().into();
let expected_core_sandbox = expected_permission_profile
.to_legacy_sandbox_policy(expected_cwd.as_path())
.expect("permission profile should project to legacy sandbox policy");
let configured = codex_protocol::protocol::SessionConfiguredEvent {
session_id: ThreadId::new(),
let expected_sandbox = SandboxPolicy::from(expected_core_sandbox);
let configured = crate::session_state::ThreadSessionState {
thread_id: ThreadId::new(),
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: expected_permission_profile.clone(),
permission_profile: expected_permission_profile,
active_permission_profile: None,
cwd: expected_cwd.clone(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: None,
};
chat.handle_codex_event(Event {
id: "session-configured".into(),
msg: EventMsg::SessionConfigured(configured),
});
chat.handle_thread_session(configured);
assert_eq!(
chat.config_ref().permissions.approval_policy.value(),
AskForApproval::from(chat.config_ref().permissions.approval_policy.value()),
AskForApproval::Never
);
let actual_sandbox = SandboxPolicy::from(chat.config_ref().legacy_sandbox_policy());
assert_eq!(&actual_sandbox, &expected_sandbox);
assert_eq!(
&chat.config_ref().legacy_sandbox_policy(),
&expected_sandbox
);
assert_eq!(
chat.config_ref().permissions.permission_profile(),
expected_permission_profile
AppServerPermissionProfile::from(chat.config_ref().permissions.permission_profile()),
expected_app_server_permission_profile
);
assert_eq!(&chat.config_ref().cwd, &expected_cwd);
@@ -332,15 +305,18 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() {
async fn session_configured_external_sandbox_keeps_external_runtime_policy() {
let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
let expected_permission_profile = PermissionProfile::External {
network: NetworkSandboxPolicy::Restricted,
let expected_app_server_permission_profile = AppServerPermissionProfile::External {
network: PermissionProfileNetworkPermissions { enabled: false },
};
let expected_sandbox = expected_permission_profile
.to_legacy_sandbox_policy(test_path_buf("/home/user/external").as_path())
.expect("external profile should project to legacy sandbox policy");
let configured = codex_protocol::protocol::SessionConfiguredEvent {
session_id: ThreadId::new(),
let expected_permission_profile: PermissionProfile =
expected_app_server_permission_profile.clone().into();
let expected_sandbox = SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Restricted,
};
let configured = crate::session_state::ThreadSessionState {
thread_id: ThreadId::new(),
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
@@ -350,33 +326,21 @@ async fn session_configured_external_sandbox_keeps_external_runtime_policy() {
permission_profile: expected_permission_profile,
active_permission_profile: None,
cwd: test_path_buf("/home/user/external").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: None,
};
chat.handle_codex_event(Event {
id: "session-configured".into(),
msg: EventMsg::SessionConfigured(configured),
});
chat.handle_thread_session(configured);
let actual_sandbox = SandboxPolicy::from(chat.config_ref().legacy_sandbox_policy());
assert_eq!(&actual_sandbox, &expected_sandbox);
assert_eq!(
&chat.config_ref().legacy_sandbox_policy(),
&expected_sandbox
);
assert_eq!(
chat.config_ref()
.permissions
.file_system_sandbox_policy()
.kind,
FileSystemSandboxKind::ExternalSandbox,
);
assert_eq!(
chat.config_ref().permissions.network_sandbox_policy(),
NetworkSandboxPolicy::Restricted,
AppServerPermissionProfile::from(chat.config_ref().permissions.permission_profile()),
expected_app_server_permission_profile
);
}
@@ -388,35 +352,36 @@ async fn replayed_user_message_with_only_remote_images_renders_history_cell() {
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = codex_protocol::protocol::SessionConfiguredEvent {
session_id: conversation_id,
let configured = crate::session_state::ThreadSessionState {
thread_id: conversation_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: codex_protocol::models::PermissionProfile::read_only(),
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
initial_messages: Some(vec![EventMsg::UserMessage(UserMessageEvent {
message: String::new(),
images: Some(remote_image_urls.clone()),
text_elements: Vec::new(),
local_images: Vec::new(),
})]),
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
chat.handle_codex_event(Event {
id: "initial".into(),
msg: EventMsg::SessionConfigured(configured),
});
chat.handle_thread_session(configured);
replay_user_message_inputs(
&mut chat,
"user-1",
vec![AppServerUserInput::Image {
url: remote_image_urls[0].clone(),
}],
ReplayKind::ResumeInitialMessages,
);
let mut user_cell = None;
while let Ok(ev) = rx.try_recv() {
@@ -438,39 +403,40 @@ async fn replayed_user_message_with_only_remote_images_renders_history_cell() {
async fn replayed_user_message_with_only_local_images_does_not_render_history_cell() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
let local_images = vec![PathBuf::from("/tmp/replay-local-only.png")];
let local_images = [PathBuf::from("/tmp/replay-local-only.png")];
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = codex_protocol::protocol::SessionConfiguredEvent {
session_id: conversation_id,
let configured = crate::session_state::ThreadSessionState {
thread_id: conversation_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: codex_protocol::models::PermissionProfile::read_only(),
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
initial_messages: Some(vec![EventMsg::UserMessage(UserMessageEvent {
message: String::new(),
images: None,
text_elements: Vec::new(),
local_images,
})]),
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
chat.handle_codex_event(Event {
id: "initial".into(),
msg: EventMsg::SessionConfigured(configured),
});
chat.handle_thread_session(configured);
replay_user_message_inputs(
&mut chat,
"user-1",
vec![AppServerUserInput::LocalImage {
path: local_images[0].clone(),
}],
ReplayKind::ResumeInitialMessages,
);
let mut found_user_history_cell = false;
while let Ok(ev) = rx.try_recv() {
@@ -630,75 +596,21 @@ async fn app_server_forked_thread_history_line_without_app_server_name_ignores_l
async fn thread_snapshot_replay_preserves_agent_message_during_review_mode() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_codex_event_replay(Event {
id: "review-start".into(),
msg: EventMsg::EnteredReviewMode(ReviewRequest {
target: ReviewTarget::UncommittedChanges,
user_facing_hint: None,
}),
});
replay_entered_review_mode(&mut chat, "current changes");
let _ = drain_insert_history(&mut rx);
chat.handle_codex_event_replay(Event {
id: "review-message".into(),
msg: EventMsg::AgentMessage(AgentMessageEvent {
message: "Review progress update".to_string(),
phase: None,
memory_citation: None,
}),
});
replay_agent_message(
&mut chat,
"review-message",
"Review progress update",
ReplayKind::ThreadSnapshot,
);
let inserted = drain_insert_history(&mut rx);
assert_eq!(inserted.len(), 1);
assert!(lines_to_single_string(&inserted[0]).contains("Review progress update"));
}
#[tokio::test]
async fn replayed_thread_rollback_emits_ordered_app_event() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
chat.replay_initial_messages(vec![EventMsg::ThreadRolledBack(ThreadRolledBackEvent {
num_turns: 2,
})]);
let mut saw = false;
while let Ok(event) = rx.try_recv() {
if let AppEvent::ApplyThreadRollback { num_turns } = event {
saw = true;
assert_eq!(num_turns, 2);
break;
}
}
assert!(saw, "expected replay rollback app event");
}
#[tokio::test]
async fn live_legacy_agent_message_after_item_completed_does_not_duplicate_assistant_message() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
complete_assistant_message(
&mut chat,
"msg-live",
"hello",
Some(MessagePhase::FinalAnswer),
);
let inserted = drain_insert_history(&mut rx);
assert_eq!(inserted.len(), 1);
assert!(lines_to_single_string(&inserted[0]).contains("hello"));
chat.handle_codex_event(Event {
id: "legacy-live".into(),
msg: EventMsg::AgentMessage(AgentMessageEvent {
message: "hello".into(),
phase: Some(MessagePhase::FinalAnswer),
memory_citation: None,
}),
});
assert!(drain_insert_history(&mut rx).is_empty());
}
#[tokio::test]
async fn replayed_retryable_app_server_error_keeps_turn_running() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -762,27 +674,25 @@ async fn replayed_thread_closed_notification_does_not_exit_tui() {
async fn replayed_reasoning_item_hides_raw_reasoning_when_disabled() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.config.show_raw_agent_reasoning = false;
chat.handle_codex_event(Event {
id: "configured".into(),
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
session_id: ThreadId::new(),
forked_from_id: None,
thread_name: None,
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: codex_protocol::models::PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_project_path().abs(),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: None,
}),
chat.handle_thread_session(crate::session_state::ThreadSessionState {
thread_id: ThreadId::new(),
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_project_path().abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
network_proxy: None,
rollout_path: None,
});
let _ = drain_insert_history(&mut rx);
@@ -810,27 +720,25 @@ async fn replayed_reasoning_item_hides_raw_reasoning_when_disabled() {
async fn replayed_reasoning_item_shows_raw_reasoning_when_enabled() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.config.show_raw_agent_reasoning = true;
chat.handle_codex_event(Event {
id: "configured".into(),
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
session_id: ThreadId::new(),
forked_from_id: None,
thread_name: None,
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: codex_protocol::models::PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_project_path().abs(),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: None,
}),
chat.handle_thread_session(crate::session_state::ThreadSessionState {
thread_id: ThreadId::new(),
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_project_path().abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
network_proxy: None,
rollout_path: None,
});
let _ = drain_insert_history(&mut rx);
@@ -908,34 +816,11 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() {
assert_eq!(rendered.matches("Summary only").count(), 1);
}
#[tokio::test]
async fn replayed_turn_started_does_not_mark_task_running() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.replay_initial_messages(vec![EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
})]);
assert!(!chat.bottom_pane.is_task_running());
assert!(chat.bottom_pane.status_widget().is_none());
}
#[tokio::test]
async fn thread_snapshot_replayed_turn_started_marks_task_running() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_codex_event_replay(Event {
id: "turn-1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
replay_turn_started(&mut chat, ReplayKind::ThreadSnapshot);
drain_insert_history(&mut rx);
assert!(chat.bottom_pane.is_task_running());
@@ -977,11 +862,12 @@ async fn replayed_stream_error_does_not_set_retry_status_or_status_indicator() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_status_header("Idle".to_string());
chat.replay_initial_messages(vec![EventMsg::StreamError(StreamErrorEvent {
message: "Reconnecting... 2/5".to_string(),
codex_error_info: Some(CodexErrorInfo::Other),
additional_details: Some("Idle timeout waiting for SSE".to_string()),
})]);
handle_stream_error_with_replay(
&mut chat,
"Reconnecting... 2/5",
Some("Idle timeout waiting for SSE".to_string()),
Some(ReplayKind::ResumeInitialMessages),
);
let cells = drain_insert_history(&mut rx);
assert!(
@@ -997,33 +883,18 @@ async fn replayed_stream_error_does_not_set_retry_status_or_status_indicator() {
async fn thread_snapshot_replayed_stream_recovery_restores_previous_status_header() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_codex_event_replay(Event {
id: "task".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
replay_turn_started(&mut chat, ReplayKind::ThreadSnapshot);
drain_insert_history(&mut rx);
chat.handle_codex_event_replay(Event {
id: "retry".into(),
msg: EventMsg::StreamError(StreamErrorEvent {
message: "Reconnecting... 1/5".to_string(),
codex_error_info: Some(CodexErrorInfo::Other),
additional_details: None,
}),
});
handle_stream_error_with_replay(
&mut chat,
"Reconnecting... 1/5",
/*additional_details*/ None,
Some(ReplayKind::ThreadSnapshot),
);
drain_insert_history(&mut rx);
chat.handle_codex_event_replay(Event {
id: "delta".into(),
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
delta: "hello".to_string(),
}),
});
replay_agent_message_delta(&mut chat, "hello", ReplayKind::ThreadSnapshot);
let status = chat
.bottom_pane
@@ -1034,93 +905,18 @@ async fn thread_snapshot_replayed_stream_recovery_restores_previous_status_heade
assert!(chat.retry_status_header.is_none());
}
#[tokio::test]
async fn resume_replay_interrupted_reconnect_does_not_leave_stale_working_state() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_status_header("Idle".to_string());
chat.replay_initial_messages(vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
EventMsg::StreamError(StreamErrorEvent {
message: "Reconnecting... 1/5".to_string(),
codex_error_info: Some(CodexErrorInfo::Other),
additional_details: None,
}),
EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
delta: "hello".to_string(),
}),
]);
let cells = drain_insert_history(&mut rx);
assert!(
cells.is_empty(),
"expected no history cells for replayed interrupted reconnect sequence"
);
assert!(!chat.bottom_pane.is_task_running());
assert!(chat.bottom_pane.status_widget().is_none());
assert_eq!(chat.current_status.header, "Idle");
assert!(chat.retry_status_header.is_none());
}
#[tokio::test]
async fn replayed_interrupted_reconnect_footer_row_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.replay_initial_messages(vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
EventMsg::StreamError(StreamErrorEvent {
message: "Reconnecting... 2/5".to_string(),
codex_error_info: Some(CodexErrorInfo::Other),
additional_details: Some("Idle timeout waiting for SSE".to_string()),
}),
]);
let header = render_bottom_first_row(&chat, /*width*/ 80);
assert!(
!header.contains("Reconnecting") && !header.contains("Working"),
"expected replayed interrupted reconnect to avoid active status row, got {header:?}"
);
assert_chatwidget_snapshot!("replayed_interrupted_reconnect_footer_row", header);
}
#[tokio::test]
async fn stream_recovery_restores_previous_status_header() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_codex_event(Event {
id: "task".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
handle_turn_started(&mut chat, "turn-1");
drain_insert_history(&mut rx);
chat.handle_codex_event(Event {
id: "retry".into(),
msg: EventMsg::StreamError(StreamErrorEvent {
message: "Reconnecting... 1/5".to_string(),
codex_error_info: Some(CodexErrorInfo::Other),
additional_details: None,
}),
});
handle_stream_error(
&mut chat,
"Reconnecting... 1/5",
/*additional_details*/ None,
);
drain_insert_history(&mut rx);
chat.handle_codex_event(Event {
id: "delta".into(),
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
delta: "hello".to_string(),
}),
});
handle_agent_message_delta(&mut chat, "hello");
let status = chat
.bottom_pane
+103 -342
View File
@@ -1,21 +1,34 @@
use super::*;
use codex_protocol::protocol::McpStartupCompleteEvent;
use codex_protocol::protocol::McpStartupStatus;
use codex_protocol::protocol::McpStartupUpdateEvent;
use pretty_assertions::assert_eq;
fn notify_mcp_status(chat: &mut ChatWidget, name: &str, status: McpServerStartupState) {
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: name.to_string(),
status,
error: None,
}),
/*replay_kind*/ None,
);
}
fn notify_mcp_status_error(chat: &mut ChatWidget, name: &str, error: &str) {
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: name.to_string(),
status: McpServerStartupState::Failed,
error: Some(error.to_string()),
}),
/*replay_kind*/ None,
);
}
#[tokio::test]
async fn mcp_startup_header_booting_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.show_welcome_banner = false;
chat.handle_codex_event(Event {
id: "mcp-1".into(),
msg: EventMsg::McpStartupUpdate(McpStartupUpdateEvent {
server: "alpha".into(),
status: McpStartupStatus::Starting,
}),
});
notify_mcp_status(&mut chat, "alpha", McpServerStartupState::Starting);
let height = chat.desired_height(/*width*/ 80);
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height))
@@ -33,26 +46,14 @@ async fn mcp_startup_header_booting_snapshot() {
async fn mcp_startup_complete_does_not_clear_running_task() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
handle_turn_started(&mut chat, "turn-1");
assert!(chat.bottom_pane.is_task_running());
assert!(chat.bottom_pane.status_indicator_visible());
chat.handle_codex_event(Event {
id: "mcp-1".into(),
msg: EventMsg::McpStartupComplete(McpStartupCompleteEvent {
ready: vec!["schaltwerk".into()],
..Default::default()
}),
});
chat.set_mcp_startup_expected_servers(["schaltwerk".to_string()]);
notify_mcp_status(&mut chat, "schaltwerk", McpServerStartupState::Starting);
notify_mcp_status(&mut chat, "schaltwerk", McpServerStartupState::Ready);
assert!(chat.bottom_pane.is_task_running());
assert!(chat.bottom_pane.status_indicator_visible());
@@ -64,25 +65,15 @@ async fn app_server_mcp_startup_failure_renders_warning_history() {
chat.show_welcome_banner = false;
chat.set_mcp_startup_expected_servers(["alpha".to_string(), "beta".to_string()]);
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: "alpha".to_string(),
status: McpServerStartupState::Starting,
error: None,
}),
/*replay_kind*/ None,
);
notify_mcp_status(&mut chat, "alpha", McpServerStartupState::Starting);
assert!(drain_insert_history(&mut rx).is_empty());
assert!(chat.bottom_pane.is_task_running());
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: "alpha".to_string(),
status: McpServerStartupState::Failed,
error: Some("MCP client for `alpha` failed to start: handshake failed".to_string()),
}),
/*replay_kind*/ None,
notify_mcp_status_error(
&mut chat,
"alpha",
"MCP client for `alpha` failed to start: handshake failed",
);
let failure_cells = drain_insert_history(&mut rx);
@@ -94,26 +85,12 @@ async fn app_server_mcp_startup_failure_renders_warning_history() {
assert!(!failure_text.contains("MCP startup incomplete"));
assert!(chat.bottom_pane.is_task_running());
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: "beta".to_string(),
status: McpServerStartupState::Starting,
error: None,
}),
/*replay_kind*/ None,
);
notify_mcp_status(&mut chat, "beta", McpServerStartupState::Starting);
assert!(drain_insert_history(&mut rx).is_empty());
assert!(chat.bottom_pane.is_task_running());
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: "beta".to_string(),
status: McpServerStartupState::Ready,
error: None,
}),
/*replay_kind*/ None,
);
notify_mcp_status(&mut chat, "beta", McpServerStartupState::Ready);
let summary_cells = drain_insert_history(&mut rx);
let summary_text = summary_cells
@@ -154,30 +131,13 @@ async fn app_server_mcp_startup_lag_settles_startup_and_ignores_late_updates() {
chat.show_welcome_banner = false;
chat.set_mcp_startup_expected_servers(["alpha".to_string(), "beta".to_string()]);
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: "alpha".to_string(),
status: McpServerStartupState::Starting,
error: None,
}),
/*replay_kind*/ None,
);
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: "alpha".to_string(),
status: McpServerStartupState::Failed,
error: Some("MCP client for `alpha` failed to start: handshake failed".to_string()),
}),
/*replay_kind*/ None,
);
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: "beta".to_string(),
status: McpServerStartupState::Starting,
error: None,
}),
/*replay_kind*/ None,
notify_mcp_status(&mut chat, "alpha", McpServerStartupState::Starting);
notify_mcp_status_error(
&mut chat,
"alpha",
"MCP client for `alpha` failed to start: handshake failed",
);
notify_mcp_status(&mut chat, "beta", McpServerStartupState::Starting);
let _ = drain_insert_history(&mut rx);
assert!(chat.bottom_pane.is_task_running());
@@ -193,26 +153,12 @@ async fn app_server_mcp_startup_lag_settles_startup_and_ignores_late_updates() {
assert!(summary_text.contains("MCP startup incomplete (failed: alpha)"));
assert!(!chat.bottom_pane.is_task_running());
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: "beta".to_string(),
status: McpServerStartupState::Starting,
error: None,
}),
/*replay_kind*/ None,
);
notify_mcp_status(&mut chat, "beta", McpServerStartupState::Starting);
assert!(drain_insert_history(&mut rx).is_empty());
assert!(!chat.bottom_pane.is_task_running());
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: "beta".to_string(),
status: McpServerStartupState::Ready,
error: None,
}),
/*replay_kind*/ None,
);
notify_mcp_status(&mut chat, "beta", McpServerStartupState::Ready);
assert!(drain_insert_history(&mut rx).is_empty());
assert!(!chat.bottom_pane.is_task_running());
@@ -226,13 +172,10 @@ async fn app_server_mcp_startup_after_lag_can_settle_without_starting_updates()
chat.finish_mcp_startup_after_lag();
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: "alpha".to_string(),
status: McpServerStartupState::Failed,
error: Some("MCP client for `alpha` failed to start: handshake failed".to_string()),
}),
/*replay_kind*/ None,
notify_mcp_status_error(
&mut chat,
"alpha",
"MCP client for `alpha` failed to start: handshake failed",
);
let failure_text = drain_insert_history(&mut rx)
@@ -242,14 +185,7 @@ async fn app_server_mcp_startup_after_lag_can_settle_without_starting_updates()
assert!(failure_text.contains("MCP client for `alpha` failed to start: handshake failed"));
assert!(chat.bottom_pane.is_task_running());
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: "beta".to_string(),
status: McpServerStartupState::Ready,
error: None,
}),
/*replay_kind*/ None,
);
notify_mcp_status(&mut chat, "beta", McpServerStartupState::Ready);
let summary_text = drain_insert_history(&mut rx)
.iter()
@@ -265,43 +201,23 @@ async fn app_server_mcp_startup_after_lag_preserves_partial_terminal_only_round(
chat.show_welcome_banner = false;
chat.set_mcp_startup_expected_servers(["alpha".to_string(), "beta".to_string()]);
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: "alpha".to_string(),
status: McpServerStartupState::Starting,
error: None,
}),
/*replay_kind*/ None,
);
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: "alpha".to_string(),
status: McpServerStartupState::Failed,
error: Some("MCP client for `alpha` failed to start: handshake failed".to_string()),
}),
/*replay_kind*/ None,
);
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: "beta".to_string(),
status: McpServerStartupState::Starting,
error: None,
}),
/*replay_kind*/ None,
notify_mcp_status(&mut chat, "alpha", McpServerStartupState::Starting);
notify_mcp_status_error(
&mut chat,
"alpha",
"MCP client for `alpha` failed to start: handshake failed",
);
notify_mcp_status(&mut chat, "beta", McpServerStartupState::Starting);
let _ = drain_insert_history(&mut rx);
chat.finish_mcp_startup_after_lag();
let _ = drain_insert_history(&mut rx);
assert!(!chat.bottom_pane.is_task_running());
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: "alpha".to_string(),
status: McpServerStartupState::Failed,
error: Some("MCP client for `alpha` failed to start: handshake failed".to_string()),
}),
/*replay_kind*/ None,
notify_mcp_status_error(
&mut chat,
"alpha",
"MCP client for `alpha` failed to start: handshake failed",
);
assert!(drain_insert_history(&mut rx).is_empty());
@@ -309,14 +225,7 @@ async fn app_server_mcp_startup_after_lag_preserves_partial_terminal_only_round(
chat.finish_mcp_startup_after_lag();
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: "beta".to_string(),
status: McpServerStartupState::Ready,
error: None,
}),
/*replay_kind*/ None,
);
notify_mcp_status(&mut chat, "beta", McpServerStartupState::Ready);
let summary_text = drain_insert_history(&mut rx)
.iter()
@@ -333,78 +242,35 @@ async fn app_server_mcp_startup_next_round_discards_stale_terminal_updates() {
chat.show_welcome_banner = false;
chat.set_mcp_startup_expected_servers(["alpha".to_string(), "beta".to_string()]);
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: "alpha".to_string(),
status: McpServerStartupState::Starting,
error: None,
}),
/*replay_kind*/ None,
);
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: "alpha".to_string(),
status: McpServerStartupState::Failed,
error: Some("MCP client for `alpha` failed to start: handshake failed".to_string()),
}),
/*replay_kind*/ None,
);
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: "beta".to_string(),
status: McpServerStartupState::Starting,
error: None,
}),
/*replay_kind*/ None,
notify_mcp_status(&mut chat, "alpha", McpServerStartupState::Starting);
notify_mcp_status_error(
&mut chat,
"alpha",
"MCP client for `alpha` failed to start: handshake failed",
);
notify_mcp_status(&mut chat, "beta", McpServerStartupState::Starting);
let _ = drain_insert_history(&mut rx);
chat.finish_mcp_startup_after_lag();
let _ = drain_insert_history(&mut rx);
assert!(!chat.bottom_pane.is_task_running());
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: "alpha".to_string(),
status: McpServerStartupState::Failed,
error: Some(
"MCP client for `alpha` failed to start: stale handshake failed".to_string(),
),
}),
/*replay_kind*/ None,
notify_mcp_status_error(
&mut chat,
"alpha",
"MCP client for `alpha` failed to start: stale handshake failed",
);
assert!(drain_insert_history(&mut rx).is_empty());
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: "beta".to_string(),
status: McpServerStartupState::Starting,
error: None,
}),
/*replay_kind*/ None,
);
notify_mcp_status(&mut chat, "beta", McpServerStartupState::Starting);
assert!(drain_insert_history(&mut rx).is_empty());
assert!(!chat.bottom_pane.is_task_running());
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: "alpha".to_string(),
status: McpServerStartupState::Ready,
error: None,
}),
/*replay_kind*/ None,
);
notify_mcp_status(&mut chat, "alpha", McpServerStartupState::Ready);
assert!(drain_insert_history(&mut rx).is_empty());
assert!(chat.bottom_pane.is_task_running());
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: "beta".to_string(),
status: McpServerStartupState::Ready,
error: None,
}),
/*replay_kind*/ None,
);
notify_mcp_status(&mut chat, "beta", McpServerStartupState::Ready);
let summary_text = drain_insert_history(&mut rx)
.iter()
@@ -422,23 +288,13 @@ async fn app_server_mcp_startup_next_round_keeps_terminal_statuses_after_startin
chat.finish_mcp_startup_after_lag();
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: "alpha".to_string(),
status: McpServerStartupState::Starting,
error: None,
}),
/*replay_kind*/ None,
);
notify_mcp_status(&mut chat, "alpha", McpServerStartupState::Starting);
assert!(drain_insert_history(&mut rx).is_empty());
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: "alpha".to_string(),
status: McpServerStartupState::Failed,
error: Some("MCP client for `alpha` failed to start: handshake failed".to_string()),
}),
/*replay_kind*/ None,
notify_mcp_status_error(
&mut chat,
"alpha",
"MCP client for `alpha` failed to start: handshake failed",
);
let failure_text = drain_insert_history(&mut rx)
@@ -447,25 +303,11 @@ async fn app_server_mcp_startup_next_round_keeps_terminal_statuses_after_startin
.collect::<String>();
assert!(failure_text.contains("MCP client for `alpha` failed to start: handshake failed"));
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: "beta".to_string(),
status: McpServerStartupState::Starting,
error: None,
}),
/*replay_kind*/ None,
);
notify_mcp_status(&mut chat, "beta", McpServerStartupState::Starting);
assert!(drain_insert_history(&mut rx).is_empty());
assert!(chat.bottom_pane.is_task_running());
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: "beta".to_string(),
status: McpServerStartupState::Ready,
error: None,
}),
/*replay_kind*/ None,
);
notify_mcp_status(&mut chat, "beta", McpServerStartupState::Ready);
let summary_text = drain_insert_history(&mut rx)
.iter()
@@ -482,24 +324,14 @@ async fn app_server_mcp_startup_next_round_with_empty_expected_servers_reactivat
chat.set_mcp_startup_expected_servers(std::iter::empty::<String>());
chat.finish_mcp_startup(Vec::new(), Vec::new());
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: "runtime".to_string(),
status: McpServerStartupState::Starting,
error: None,
}),
/*replay_kind*/ None,
);
notify_mcp_status(&mut chat, "runtime", McpServerStartupState::Starting);
assert!(drain_insert_history(&mut rx).is_empty());
assert!(chat.bottom_pane.is_task_running());
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: "runtime".to_string(),
status: McpServerStartupState::Failed,
error: Some("MCP client for `runtime` failed to start: handshake failed".to_string()),
}),
/*replay_kind*/ None,
notify_mcp_status_error(
&mut chat,
"runtime",
"MCP client for `runtime` failed to start: handshake failed",
);
let summary_text = drain_insert_history(&mut rx)
@@ -511,56 +343,17 @@ async fn app_server_mcp_startup_next_round_with_empty_expected_servers_reactivat
assert!(!chat.bottom_pane.is_task_running());
}
#[tokio::test]
async fn app_server_mcp_startup_after_lag_with_empty_expected_servers_preserves_failures() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.show_welcome_banner = false;
chat.set_mcp_startup_expected_servers(std::iter::empty::<String>());
chat.on_mcp_startup_update(McpStartupUpdateEvent {
server: "runtime".to_string(),
status: McpStartupStatus::Starting,
});
chat.on_mcp_startup_update(McpStartupUpdateEvent {
server: "runtime".to_string(),
status: McpStartupStatus::Failed {
error: "MCP client for `runtime` failed to start: handshake failed".to_string(),
},
});
let warning_text = drain_insert_history(&mut rx)
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
assert!(warning_text.contains("MCP client for `runtime` failed to start: handshake failed"));
assert!(chat.bottom_pane.is_task_running());
chat.finish_mcp_startup_after_lag();
let summary_text = drain_insert_history(&mut rx)
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
assert!(summary_text.contains("MCP startup incomplete (failed: runtime)"));
assert!(!chat.bottom_pane.is_task_running());
}
#[tokio::test]
async fn app_server_mcp_startup_after_lag_includes_runtime_servers_with_expected_set() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.show_welcome_banner = false;
chat.set_mcp_startup_expected_servers(["alpha".to_string()]);
chat.on_mcp_startup_update(McpStartupUpdateEvent {
server: "alpha".to_string(),
status: McpStartupStatus::Ready,
});
chat.on_mcp_startup_update(McpStartupUpdateEvent {
server: "runtime".to_string(),
status: McpStartupStatus::Failed {
error: "MCP client for `runtime` failed to start: handshake failed".to_string(),
},
});
notify_mcp_status_error(
&mut chat,
"runtime",
"MCP client for `runtime` failed to start: handshake failed",
);
let warning_text = drain_insert_history(&mut rx)
.iter()
@@ -585,57 +378,32 @@ async fn app_server_mcp_startup_next_round_after_lag_can_settle_without_starting
chat.show_welcome_banner = false;
chat.set_mcp_startup_expected_servers(["alpha".to_string(), "beta".to_string()]);
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: "alpha".to_string(),
status: McpServerStartupState::Starting,
error: None,
}),
/*replay_kind*/ None,
);
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: "alpha".to_string(),
status: McpServerStartupState::Failed,
error: Some("MCP client for `alpha` failed to start: handshake failed".to_string()),
}),
/*replay_kind*/ None,
);
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: "beta".to_string(),
status: McpServerStartupState::Starting,
error: None,
}),
/*replay_kind*/ None,
notify_mcp_status(&mut chat, "alpha", McpServerStartupState::Starting);
notify_mcp_status_error(
&mut chat,
"alpha",
"MCP client for `alpha` failed to start: handshake failed",
);
notify_mcp_status(&mut chat, "beta", McpServerStartupState::Starting);
let _ = drain_insert_history(&mut rx);
chat.finish_mcp_startup_after_lag();
let _ = drain_insert_history(&mut rx);
assert!(!chat.bottom_pane.is_task_running());
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: "alpha".to_string(),
status: McpServerStartupState::Failed,
error: Some(
"MCP client for `alpha` failed to start: stale handshake failed".to_string(),
),
}),
/*replay_kind*/ None,
notify_mcp_status_error(
&mut chat,
"alpha",
"MCP client for `alpha` failed to start: stale handshake failed",
);
assert!(drain_insert_history(&mut rx).is_empty());
chat.finish_mcp_startup_after_lag();
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: "alpha".to_string(),
status: McpServerStartupState::Failed,
error: Some("MCP client for `alpha` failed to start: handshake failed".to_string()),
}),
/*replay_kind*/ None,
notify_mcp_status_error(
&mut chat,
"alpha",
"MCP client for `alpha` failed to start: handshake failed",
);
let failure_text = drain_insert_history(&mut rx)
@@ -645,14 +413,7 @@ async fn app_server_mcp_startup_next_round_after_lag_can_settle_without_starting
assert!(failure_text.is_empty());
assert!(!chat.bottom_pane.is_task_running());
chat.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: "beta".to_string(),
status: McpServerStartupState::Ready,
error: None,
}),
/*replay_kind*/ None,
);
notify_mcp_status(&mut chat, "beta", McpServerStartupState::Ready);
let summary_text = drain_insert_history(&mut rx)
.iter()
+112 -81
View File
@@ -1,12 +1,53 @@
use super::*;
use codex_protocol::models::ManagedFileSystemPermissions;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::protocol::NetworkSandboxPolicy;
use codex_app_server_protocol::FileSystemAccessMode;
use codex_app_server_protocol::FileSystemPath;
use codex_app_server_protocol::FileSystemSandboxEntry;
use codex_app_server_protocol::FileSystemSpecialPath;
use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile;
use codex_app_server_protocol::PermissionProfileFileSystemPermissions;
use codex_app_server_protocol::PermissionProfileNetworkPermissions;
use pretty_assertions::assert_eq;
fn app_server_workspace_write_profile(extra_root: AbsolutePathBuf) -> PermissionProfile {
AppServerPermissionProfile::Managed {
network: PermissionProfileNetworkPermissions { enabled: false },
file_system: PermissionProfileFileSystemPermissions::Restricted {
entries: vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::ProjectRoots { subpath: None },
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::SlashTmp,
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Tmpdir,
},
access: FileSystemAccessMode::Write,
},
FileSystemSandboxEntry {
path: FileSystemPath::Path { path: extra_root },
access: FileSystemAccessMode::Write,
},
],
glob_scan_max_depth: None,
},
}
.into()
}
#[tokio::test]
async fn approvals_selection_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -57,12 +98,7 @@ async fn preset_matching_accepts_workspace_write_with_extra_roots() {
.into_iter()
.find(|p| p.id == "auto")
.expect("auto preset exists");
let current_profile = PermissionProfile::workspace_write_with(
&[test_path_buf("/tmp/extra").abs()],
NetworkSandboxPolicy::Restricted,
/*exclude_tmpdir_env_var*/ false,
/*exclude_slash_tmp*/ false,
);
let current_profile = app_server_workspace_write_profile(test_path_buf("/tmp/extra").abs());
let cwd = test_path_buf("/tmp/project").abs();
assert!(
@@ -91,8 +127,9 @@ async fn preset_matching_does_not_treat_non_cwd_writable_profile_as_read_only()
.into_iter()
.find(|p| p.id == "read-only")
.expect("read-only preset exists");
let current_profile = PermissionProfile::Managed {
file_system: ManagedFileSystemPermissions::Restricted {
let current_profile: PermissionProfile = AppServerPermissionProfile::Managed {
network: PermissionProfileNetworkPermissions { enabled: false },
file_system: PermissionProfileFileSystemPermissions::Restricted {
entries: vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
@@ -109,8 +146,8 @@ async fn preset_matching_does_not_treat_non_cwd_writable_profile_as_read_only()
],
glob_scan_max_depth: None,
},
network: NetworkSandboxPolicy::Restricted,
};
}
.into();
let cwd = test_path_buf("/tmp/project").abs();
assert!(
@@ -208,15 +245,17 @@ async fn startup_does_not_prompt_for_windows_sandbox_when_not_requested() {
async fn approvals_popup_shows_disabled_presets() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.config.permissions.approval_policy =
Constrained::new(AskForApproval::OnRequest, |candidate| match candidate {
chat.config.permissions.approval_policy = Constrained::new(
AskForApproval::OnRequest.to_core(),
|candidate| match AskForApproval::from(*candidate) {
AskForApproval::OnRequest => Ok(()),
_ => Err(invalid_value(
candidate.to_string(),
"this message should be printed in the description",
)),
})
.expect("construct constrained approval policy");
},
)
.expect("construct constrained approval policy");
chat.open_approvals_popup();
let width = 80;
@@ -245,12 +284,14 @@ async fn approvals_popup_navigation_skips_disabled() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::GuardianApproval, /*enabled*/ false);
chat.config.permissions.approval_policy =
Constrained::new(AskForApproval::OnRequest, |candidate| match candidate {
chat.config.permissions.approval_policy = Constrained::new(
AskForApproval::OnRequest.to_core(),
|candidate| match AskForApproval::from(*candidate) {
AskForApproval::OnRequest => Ok(()),
_ => Err(invalid_value(candidate.to_string(), "[on-request]")),
})
.expect("construct constrained approval policy");
},
)
.expect("construct constrained approval policy");
chat.open_approvals_popup();
let popup = render_bottom_popup(&chat, /*width*/ 80);
@@ -315,7 +356,7 @@ async fn approvals_popup_navigation_skips_disabled() {
assert!(
app_events.iter().any(|ev| matches!(
ev,
AppEvent::CodexOp(AppCommand::OverrideTurnContext {
AppEvent::CodexOp(Op::OverrideTurnContext {
approval_policy: Some(AskForApproval::OnRequest),
personality: None,
..
@@ -326,7 +367,7 @@ async fn approvals_popup_navigation_skips_disabled() {
assert!(
!app_events.iter().any(|ev| matches!(
ev,
AppEvent::CodexOp(AppCommand::OverrideTurnContext {
AppEvent::CodexOp(Op::OverrideTurnContext {
approval_policy: Some(AskForApproval::Never),
personality: None,
..
@@ -400,7 +441,7 @@ async fn permissions_selection_history_snapshot_full_access_to_default() {
chat.config
.permissions
.approval_policy
.set(AskForApproval::Never)
.set(AskForApproval::Never.to_core())
.expect("set approval policy");
chat.config
.permissions
@@ -442,7 +483,7 @@ async fn permissions_selection_emits_history_cell_when_current_is_selected() {
chat.config
.permissions
.approval_policy
.set(AskForApproval::OnRequest)
.set(AskForApproval::OnRequest.to_core())
.expect("set approval policy");
chat.config
.permissions
@@ -500,7 +541,7 @@ async fn permissions_selection_hides_auto_review_when_feature_disabled_even_if_a
chat.config
.permissions
.approval_policy
.set(AskForApproval::OnRequest)
.set(AskForApproval::OnRequest.to_core())
.expect("set approval policy");
chat.config
.permissions
@@ -530,27 +571,25 @@ async fn permissions_selection_marks_auto_review_current_after_session_configure
.features
.set_enabled(Feature::GuardianApproval, /*enabled*/ true);
chat.handle_codex_event(Event {
id: "session-configured".to_string(),
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
session_id: ThreadId::new(),
forked_from_id: None,
thread_name: None,
model: "gpt-test".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::OnRequest,
approvals_reviewer: ApprovalsReviewer::AutoReview,
permission_profile: PermissionProfile::workspace_write(),
active_permission_profile: None,
cwd: test_project_path().abs(),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
}),
chat.handle_thread_session(crate::session_state::ThreadSessionState {
thread_id: ThreadId::new(),
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "gpt-test".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::OnRequest,
approvals_reviewer: ApprovalsReviewer::AutoReview,
permission_profile: PermissionProfile::workspace_write(),
active_permission_profile: None,
cwd: test_project_path().abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
});
chat.open_permissions_popup();
@@ -578,34 +617,27 @@ async fn permissions_selection_marks_auto_review_current_with_custom_workspace_w
let extra_root = test_path_buf("/tmp/guardian-approvals-extra").abs();
let cwd = test_project_path().abs();
let permission_profile = PermissionProfile::workspace_write_with(
&[extra_root],
NetworkSandboxPolicy::Restricted,
/*exclude_tmpdir_env_var*/ false,
/*exclude_slash_tmp*/ false,
);
let permission_profile = app_server_workspace_write_profile(extra_root);
chat.handle_codex_event(Event {
id: "session-configured-custom-workspace".to_string(),
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
session_id: ThreadId::new(),
forked_from_id: None,
thread_name: None,
model: "gpt-test".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::OnRequest,
approvals_reviewer: ApprovalsReviewer::AutoReview,
permission_profile,
active_permission_profile: None,
cwd,
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
}),
chat.handle_thread_session(crate::session_state::ThreadSessionState {
thread_id: ThreadId::new(),
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "gpt-test".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::OnRequest,
approvals_reviewer: ApprovalsReviewer::AutoReview,
permission_profile,
active_permission_profile: None,
cwd,
instruction_source_paths: Vec::new(),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
});
chat.open_permissions_popup();
@@ -630,7 +662,7 @@ async fn permissions_selection_can_disable_auto_review() {
chat.config
.permissions
.approval_policy
.set(AskForApproval::OnRequest)
.set(AskForApproval::OnRequest.to_core())
.expect("set approval policy");
chat.config
.permissions
@@ -670,7 +702,7 @@ async fn permissions_selection_sends_approvals_reviewer_in_override_turn_context
chat.config
.permissions
.approval_policy
.set(AskForApproval::OnRequest)
.set(AskForApproval::OnRequest.to_core())
.expect("set approval policy");
chat.config
.permissions
@@ -699,7 +731,7 @@ async fn permissions_selection_sends_approvals_reviewer_in_override_turn_context
let op = std::iter::from_fn(|| rx.try_recv().ok())
.find_map(|event| match event {
AppEvent::CodexOp(op @ AppCommand::OverrideTurnContext { .. }) => Some(op),
AppEvent::CodexOp(op @ Op::OverrideTurnContext { .. }) => Some(op),
_ => None,
})
.expect("expected OverrideTurnContext op");
@@ -710,7 +742,6 @@ async fn permissions_selection_sends_approvals_reviewer_in_override_turn_context
cwd: None,
approval_policy: Some(AskForApproval::OnRequest),
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
sandbox_policy: None,
permission_profile: Some(PermissionProfile::workspace_write()),
windows_sandbox_level: None,
model: None,
+75 -68
View File
@@ -582,16 +582,17 @@ async fn request_user_input_notification_overrides_pending_agent_turn_complete_n
chat.notify(Notification::AgentTurnComplete {
response: "done".to_string(),
});
chat.handle_request_user_input_now(RequestUserInputEvent {
call_id: "call-1".to_string(),
chat.handle_request_user_input_now(ToolRequestUserInputParams {
thread_id: "thread-1".to_string(),
item_id: "call-1".to_string(),
turn_id: "turn-1".to_string(),
questions: vec![RequestUserInputQuestion {
questions: vec![ToolRequestUserInputQuestion {
id: "reasoning_scope".to_string(),
header: "Reasoning scope".to_string(),
question: "Which reasoning scope should I use?".to_string(),
is_other: false,
is_secret: false,
options: Some(vec![RequestUserInputQuestionOption {
options: Some(vec![ToolRequestUserInputOption {
label: "Plan only".to_string(),
description: "Update only Plan mode.".to_string(),
}]),
@@ -610,16 +611,17 @@ async fn handle_request_user_input_sets_pending_notification() {
chat.config.tui_notifications.notifications =
Notifications::Custom(vec!["plan-mode-prompt".to_string()]);
chat.handle_request_user_input_now(RequestUserInputEvent {
call_id: "call-1".to_string(),
chat.handle_request_user_input_now(ToolRequestUserInputParams {
thread_id: "thread-1".to_string(),
item_id: "call-1".to_string(),
turn_id: "turn-1".to_string(),
questions: vec![RequestUserInputQuestion {
questions: vec![ToolRequestUserInputQuestion {
id: "reasoning_scope".to_string(),
header: "Reasoning scope".to_string(),
question: "Which reasoning scope should I use?".to_string(),
is_other: false,
is_secret: false,
options: Some(vec![RequestUserInputQuestionOption {
options: Some(vec![ToolRequestUserInputOption {
label: "Plan only".to_string(),
description: "Update only Plan mode.".to_string(),
}]),
@@ -802,13 +804,23 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() {
.expect("expected plan collaboration mask");
chat.set_collaboration_mask(plan_mask);
chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-1".to_string(),
last_agent_message: Some("Plan details".to_string()),
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
})]);
chat.replay_thread_turns(
vec![AppServerTurn {
id: "turn-1".to_string(),
items: vec![AppServerThreadItem::AgentMessage {
id: "msg-plan".to_string(),
text: "Plan details".to_string(),
phase: Some(MessagePhase::FinalAnswer),
memory_citation: None,
}],
status: AppServerTurnStatus::Completed,
error: None,
started_at: None,
completed_at: None,
duration_ms: None,
}],
ReplayKind::ResumeInitialMessages,
);
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert!(
@@ -829,29 +841,36 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com
chat.on_plan_delta("- Step 1\n- Step 2\n".to_string());
chat.on_plan_item_completed("- Step 1\n- Step 2\n".to_string());
chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-1".to_string(),
last_agent_message: Some("Plan details".to_string()),
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
})]);
chat.replay_thread_turns(
vec![AppServerTurn {
id: "turn-1".to_string(),
items: vec![AppServerThreadItem::AgentMessage {
id: "msg-plan-replay".to_string(),
text: "Plan details".to_string(),
phase: Some(MessagePhase::FinalAnswer),
memory_citation: None,
}],
status: AppServerTurnStatus::Completed,
error: None,
started_at: None,
completed_at: None,
duration_ms: None,
}],
ReplayKind::ResumeInitialMessages,
);
let replay_popup = render_bottom_popup(&chat, /*width*/ 80);
assert!(
!replay_popup.contains(PLAN_IMPLEMENTATION_TITLE),
"expected no prompt for replayed turn completion, got {replay_popup:?}"
);
chat.handle_codex_event(Event {
id: "live-turn-complete-1".to_string(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-1".to_string(),
last_agent_message: Some("Plan details".to_string()),
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
}),
});
complete_assistant_message(
&mut chat,
"msg-plan-live-1",
"Plan details",
Some(MessagePhase::FinalAnswer),
);
handle_turn_completed(&mut chat, "live-turn-complete-1", /*duration_ms*/ None);
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert!(
@@ -866,16 +885,13 @@ async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_com
"expected prompt to dismiss on Esc, got {dismissed_popup:?}"
);
chat.handle_codex_event(Event {
id: "live-turn-complete-2".to_string(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-1".to_string(),
last_agent_message: Some("Plan details".to_string()),
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
}),
});
complete_assistant_message(
&mut chat,
"msg-plan-live-2",
"Plan details",
Some(MessagePhase::FinalAnswer),
);
handle_turn_completed(&mut chat, "live-turn-complete-2", /*duration_ms*/ None);
let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80);
assert!(
!duplicate_popup.contains(PLAN_IMPLEMENTATION_TITLE),
@@ -1137,15 +1153,13 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() {
other => panic!("expected running-turn compact steer submit, got {other:?}"),
}
chat.handle_codex_event(Event {
id: "steer-rejected".into(),
msg: EventMsg::Error(ErrorEvent {
message: "cannot steer a compact turn".to_string(),
codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable {
turn_kind: NonSteerableTurnKind::Compact,
}),
handle_error(
&mut chat,
"cannot steer a compact turn",
Some(CodexErrorInfo::ActiveTurnNotSteerable {
turn_kind: NonSteerableTurnKind::Compact,
}),
});
);
assert!(chat.pending_steers.is_empty());
assert_eq!(
@@ -1186,9 +1200,10 @@ async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = codex_protocol::protocol::SessionConfiguredEvent {
session_id: conversation_id,
let configured = crate::session_state::ThreadSessionState {
thread_id: conversation_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
@@ -1198,17 +1213,14 @@ async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() {
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
};
chat.handle_codex_event(Event {
id: "initial".into(),
msg: EventMsg::SessionConfigured(configured),
});
chat.handle_thread_session(configured);
chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
chat.bottom_pane
.set_plugin_mentions(Some(vec![codex_plugin::PluginCapabilitySummary {
@@ -1432,9 +1444,10 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
let configured = codex_protocol::protocol::SessionConfiguredEvent {
session_id: ThreadId::new(),
let configured = crate::session_state::ThreadSessionState {
thread_id: ThreadId::new(),
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
@@ -1444,17 +1457,14 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() {
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: None,
};
chat.handle_codex_event(Event {
id: "configured".into(),
msg: EventMsg::SessionConfigured(configured),
});
chat.handle_thread_session(configured);
chat.bottom_pane
.set_composer_text("/plan build the plan".to_string(), Vec::new(), Vec::new());
@@ -1654,10 +1664,7 @@ async fn plan_update_renders_history_cell() {
},
],
};
chat.handle_codex_event(Event {
id: "sub-1".into(),
msg: EventMsg::PlanUpdate(update),
});
chat.on_plan_update(update);
let cells = drain_insert_history(&mut rx);
assert!(!cells.is_empty(), "expected plan update cell to be sent");
let blob = lines_to_single_string(cells.last().unwrap());
@@ -9,12 +9,14 @@ async fn realtime_error_closes_without_followup_closed_info() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.realtime_conversation.phase = RealtimeConversationPhase::Active;
chat.on_realtime_conversation_realtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::Error("boom".to_string()),
chat.on_realtime_error(ThreadRealtimeErrorNotification {
thread_id: ThreadId::new().to_string(),
message: "boom".to_string(),
});
next_realtime_close_op(&mut op_rx);
chat.on_realtime_conversation_closed(RealtimeConversationClosedEvent {
chat.on_realtime_conversation_closed(ThreadRealtimeClosedNotification {
thread_id: ThreadId::new().to_string(),
reason: Some("error".to_string()),
});
@@ -2323,13 +2325,11 @@ async fn server_overloaded_error_does_not_switch_models() {
while rx.try_recv().is_ok() {}
while op_rx.try_recv().is_ok() {}
chat.handle_codex_event(Event {
id: "err-1".to_string(),
msg: EventMsg::Error(ErrorEvent {
message: "server overloaded".to_string(),
codex_error_info: Some(CodexErrorInfo::ServerOverloaded),
}),
});
handle_error(
&mut chat,
"server overloaded",
Some(CodexErrorInfo::ServerOverloaded),
);
while let Ok(event) = rx.try_recv() {
if let AppEvent::UpdateModel(model) = event {
+67 -367
View File
@@ -62,15 +62,7 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() {
// When interrupted, queued messages are merged into the composer; image placeholders
// must be renumbered to match the combined local image list.
chat.handle_codex_event(Event {
id: "interrupt".into(),
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::Interrupted,
completed_at: None,
duration_ms: None,
}),
});
handle_turn_interrupted(&mut chat, "turn-1");
let first = "[Image #1] first".to_string();
let second = "[Image #2] second".to_string();
@@ -111,15 +103,7 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() {
async fn entered_review_mode_uses_request_hint() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_codex_event(Event {
id: "review-start".into(),
msg: EventMsg::EnteredReviewMode(ReviewRequest {
target: ReviewTarget::BaseBranch {
branch: "feature".to_string(),
},
user_facing_hint: Some("feature branch".to_string()),
}),
});
handle_entered_review_mode(&mut chat, "feature branch");
let cells = drain_insert_history(&mut rx);
let banner = lines_to_single_string(cells.last().expect("review banner"));
@@ -132,13 +116,7 @@ async fn entered_review_mode_uses_request_hint() {
async fn entered_review_mode_defaults_to_current_changes_banner() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_codex_event(Event {
id: "review-start".into(),
msg: EventMsg::EnteredReviewMode(ReviewRequest {
target: ReviewTarget::UncommittedChanges,
user_facing_hint: None,
}),
});
handle_entered_review_mode(&mut chat, "current changes");
let cells = drain_insert_history(&mut rx);
let banner = lines_to_single_string(cells.last().expect("review banner"));
@@ -147,18 +125,10 @@ async fn entered_review_mode_defaults_to_current_changes_banner() {
}
#[tokio::test]
async fn live_core_review_prompt_item_is_not_rendered() {
async fn live_review_prompt_item_is_not_rendered() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_codex_event(Event {
id: "review-start".into(),
msg: EventMsg::EnteredReviewMode(ReviewRequest {
target: ReviewTarget::BaseBranch {
branch: "main".to_string(),
},
user_facing_hint: Some("changes against 'main'".to_string()),
}),
});
handle_entered_review_mode(&mut chat, "changes against 'main'");
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1);
assert!(lines_to_single_string(&cells[0]).contains("Code review started"));
@@ -224,24 +194,8 @@ async fn live_app_server_review_prompt_item_is_not_rendered() {
async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
chat.handle_codex_event(Event {
id: "review-start".into(),
msg: EventMsg::EnteredReviewMode(ReviewRequest {
target: ReviewTarget::BaseBranch {
branch: "feature".to_string(),
},
user_facing_hint: Some("feature branch".to_string()),
}),
});
handle_turn_started(&mut chat, "turn-1");
handle_entered_review_mode(&mut chat, "feature branch");
let _ = drain_insert_history(&mut rx);
chat.queued_user_messages
.push_back(UserMessage::from("queued later").into());
@@ -271,24 +225,20 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages
other => panic!("expected second running-turn steer submit, got {other:?}"),
}
chat.handle_codex_event(Event {
id: "steer-rejected-1".into(),
msg: EventMsg::Error(ErrorEvent {
message: "cannot steer a review turn".to_string(),
codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable {
turn_kind: NonSteerableTurnKind::Review,
}),
handle_error(
&mut chat,
"cannot steer a review turn",
Some(CodexErrorInfo::ActiveTurnNotSteerable {
turn_kind: NonSteerableTurnKind::Review,
}),
});
chat.handle_codex_event(Event {
id: "steer-rejected-2".into(),
msg: EventMsg::Error(ErrorEvent {
message: "cannot steer a review turn".to_string(),
codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable {
turn_kind: NonSteerableTurnKind::Review,
}),
);
handle_error(
&mut chat,
"cannot steer a review turn",
Some(CodexErrorInfo::ActiveTurnNotSteerable {
turn_kind: NonSteerableTurnKind::Review,
}),
});
);
assert!(chat.pending_steers.is_empty());
assert_eq!(
@@ -301,22 +251,8 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages
);
assert!(drain_insert_history(&mut rx).is_empty());
chat.handle_codex_event(Event {
id: "review-exit".into(),
msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent {
review_output: None,
}),
});
chat.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-1".to_string(),
last_agent_message: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
}),
});
handle_exited_review_mode(&mut chat);
handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None);
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
@@ -329,16 +265,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages
other => panic!("expected merged rejected-steer follow-up submit, got {other:?}"),
}
chat.handle_codex_event(Event {
id: "turn-complete-2".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-2".to_string(),
last_agent_message: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
}),
});
handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None);
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
@@ -356,23 +283,15 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages
async fn live_agent_message_renders_during_review_mode() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_codex_event(Event {
id: "review-start".into(),
msg: EventMsg::EnteredReviewMode(ReviewRequest {
target: ReviewTarget::UncommittedChanges,
user_facing_hint: None,
}),
});
handle_entered_review_mode(&mut chat, "current changes");
let _ = drain_insert_history(&mut rx);
chat.handle_codex_event(Event {
id: "review-message".into(),
msg: EventMsg::AgentMessage(AgentMessageEvent {
message: "Review progress update".to_string(),
phase: None,
memory_citation: None,
}),
});
complete_assistant_message(
&mut chat,
"review-message",
"Review progress update",
/*phase*/ None,
);
let inserted = drain_insert_history(&mut rx);
assert_eq!(inserted.len(), 1);
@@ -388,40 +307,21 @@ async fn review_restores_context_window_indicator() {
let pre_review_tokens = 12_700; // ~30% remaining after subtracting baseline.
let review_tokens = 12_030; // ~97% remaining after subtracting baseline.
chat.handle_codex_event(Event {
id: "token-before".into(),
msg: EventMsg::TokenCount(TokenCountEvent {
info: Some(make_token_info(pre_review_tokens, context_window)),
rate_limits: None,
}),
});
handle_token_count(
&mut chat,
Some(make_token_info(pre_review_tokens, context_window)),
);
assert_eq!(chat.bottom_pane.context_window_percent(), Some(30));
chat.handle_codex_event(Event {
id: "review-start".into(),
msg: EventMsg::EnteredReviewMode(ReviewRequest {
target: ReviewTarget::BaseBranch {
branch: "feature".to_string(),
},
user_facing_hint: Some("feature branch".to_string()),
}),
});
handle_entered_review_mode(&mut chat, "feature branch");
chat.handle_codex_event(Event {
id: "token-review".into(),
msg: EventMsg::TokenCount(TokenCountEvent {
info: Some(make_token_info(review_tokens, context_window)),
rate_limits: None,
}),
});
handle_token_count(
&mut chat,
Some(make_token_info(review_tokens, context_window)),
);
assert_eq!(chat.bottom_pane.context_window_percent(), Some(97));
chat.handle_codex_event(Event {
id: "review-end".into(),
msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent {
review_output: None,
}),
});
handle_exited_review_mode(&mut chat);
let _ = drain_insert_history(&mut rx);
assert_eq!(chat.bottom_pane.context_window_percent(), Some(30));
@@ -651,7 +551,7 @@ async fn item_completed_pops_pending_steer_with_local_image_and_text_elements()
"user-1",
vec![
UserInput::Image {
image_url: "data:image/png;base64,placeholder".to_string(),
url: "data:image/png;base64,placeholder".to_string(),
},
UserInput::Text {
text,
@@ -932,10 +832,9 @@ async fn manual_interrupt_restores_pending_steer_mention_bindings_to_composer()
items,
vec![UserInput::Text {
text: "please use $figma".to_string(),
text_elements: vec![TextElement::new(
(11..17).into(),
Some("$figma".to_string()),
)],
text_elements: vec![
TextElement::new((11..17).into(), Some("$figma".to_string())).into()
],
}]
),
other => panic!("expected Op::UserTurn, got {other:?}"),
@@ -990,61 +889,6 @@ queued draft"
assert_no_submit_op(&mut op_rx);
}
#[tokio::test]
async fn replaced_turn_clears_pending_steers_but_keeps_queued_drafts() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.on_task_started();
chat.on_agent_message_delta(
"Final answer line
"
.to_string(),
);
chat.bottom_pane
.set_composer_text("pending steer".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
chat.queued_user_messages
.push_back(UserMessage::from("queued draft".to_string()).into());
chat.refresh_pending_input_preview();
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "pending steer".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected Op::UserTurn, got {other:?}"),
}
assert!(drain_insert_history(&mut rx).is_empty());
chat.handle_codex_event(Event {
id: "replaced".into(),
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::Replaced,
completed_at: None,
duration_ms: None,
}),
});
assert!(chat.pending_steers.is_empty());
assert!(chat.queued_user_messages.is_empty());
assert_eq!(chat.bottom_pane.composer_text(), "");
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "queued draft".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected queued draft Op::UserTurn, got {other:?}"),
}
}
#[tokio::test]
async fn ctrl_c_shutdown_works_with_caps_lock() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -1221,17 +1065,14 @@ async fn custom_prompt_submit_sends_review_op() {
chat.handle_paste(" please audit dependencies ".to_string());
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
// Expect AppEvent::CodexOp(AppCommand::Review { .. }) with trimmed prompt
// Expect AppEvent::CodexOp(Op::Review { .. }) with trimmed prompt
let evt = rx.try_recv().expect("expected one app event");
match evt {
AppEvent::CodexOp(AppCommand::Review { review_request }) => {
AppEvent::CodexOp(Op::Review { target }) => {
assert_eq!(
review_request,
ReviewRequest {
target: ReviewTarget::Custom {
instructions: "please audit dependencies".to_string(),
},
user_facing_hint: None,
target,
ReviewTarget::Custom {
instructions: "please audit dependencies".to_string(),
}
);
}
@@ -1263,15 +1104,7 @@ async fn interrupt_exec_marks_failed_snapshot() {
// Simulate the task being aborted (as if ESC was pressed), which should
// cause the active exec cell to be finalized as failed and flushed.
chat.handle_codex_event(Event {
id: "call-int".into(),
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::Interrupted,
completed_at: None,
duration_ms: None,
}),
});
handle_turn_interrupted(&mut chat, "turn-1");
let cells = drain_insert_history(&mut rx);
assert!(
@@ -1291,26 +1124,10 @@ async fn interrupted_turn_error_message_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
// Simulate an in-progress task so the widget is in a running state.
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
handle_turn_started(&mut chat, "turn-1");
// Abort the turn (like pressing Esc) and drain inserted history.
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::Interrupted,
completed_at: None,
duration_ms: None,
}),
});
handle_turn_interrupted(&mut chat, "turn-1");
let cells = drain_insert_history(&mut rx);
assert!(
@@ -1389,24 +1206,8 @@ async fn interrupted_turn_after_goal_budget_limited_uses_budget_message_snapshot
async fn direct_budget_limited_turn_uses_budget_message_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::BudgetLimited,
completed_at: None,
duration_ms: None,
}),
});
handle_turn_started(&mut chat, "turn-1");
handle_budget_limited_turn(&mut chat, "turn-1");
let cells = drain_insert_history(&mut rx);
let last = lines_to_single_string(cells.last().unwrap());
@@ -1420,24 +1221,8 @@ async fn budget_limited_turn_restores_queued_input_without_submitting() {
.push_back(UserMessage::from("follow-up after budget stop").into());
chat.refresh_pending_input_preview();
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::BudgetLimited,
completed_at: None,
duration_ms: None,
}),
});
handle_turn_started(&mut chat, "turn-1");
handle_budget_limited_turn(&mut chat, "turn-1");
assert!(chat.queued_user_messages.is_empty());
assert_eq!(
@@ -1457,25 +1242,9 @@ async fn interrupted_turn_pending_steers_message_snapshot() {
chat.pending_steers.push_back(pending_steer("steer 1"));
chat.submit_pending_steers_after_interrupt = true;
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
handle_turn_started(&mut chat, "turn-1");
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::Interrupted,
completed_at: None,
duration_ms: None,
}),
});
handle_turn_interrupted(&mut chat, "turn-1");
let cells = drain_insert_history(&mut rx);
let info = cells
@@ -1557,66 +1326,13 @@ async fn review_branch_picker_escape_navigates_back_then_dismisses() {
);
}
#[tokio::test]
async fn review_ended_keeps_unified_exec_processes() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
begin_unified_exec_startup(&mut chat, "call-1", "process-1", "sleep 5");
begin_unified_exec_startup(&mut chat, "call-2", "process-2", "sleep 6");
assert_eq!(chat.unified_exec_processes.len(), 2);
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::ReviewEnded,
completed_at: None,
duration_ms: None,
}),
});
assert_eq!(chat.unified_exec_processes.len(), 2);
chat.add_ps_output();
let cells = drain_insert_history(&mut rx);
let combined = cells
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<Vec<_>>()
.join("\n");
assert!(
combined.contains("Background terminals"),
"expected /ps to remain available after review-ended abort; got {combined:?}"
);
assert!(
combined.contains("sleep 5") && combined.contains("sleep 6"),
"expected /ps to list running unified exec processes; got {combined:?}"
);
let _ = drain_insert_history(&mut rx);
}
#[tokio::test]
async fn enter_submits_steer_while_review_is_running() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
handle_turn_started(&mut chat, "turn-1");
chat.handle_codex_event(Event {
id: "review-1".into(),
msg: EventMsg::EnteredReviewMode(ReviewRequest {
target: ReviewTarget::UncommittedChanges,
user_facing_hint: Some("current changes".to_string()),
}),
});
handle_entered_review_mode(&mut chat, "current changes");
let _ = drain_insert_history(&mut rx);
chat.bottom_pane.set_composer_text(
@@ -1649,37 +1365,21 @@ async fn enter_submits_steer_while_review_is_running() {
async fn review_queues_user_messages_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
handle_turn_started(&mut chat, "turn-1");
chat.handle_codex_event(Event {
id: "review-1".into(),
msg: EventMsg::EnteredReviewMode(ReviewRequest {
target: ReviewTarget::UncommittedChanges,
user_facing_hint: Some("current changes".to_string()),
}),
});
handle_entered_review_mode(&mut chat, "current changes");
let _ = drain_insert_history(&mut rx);
chat.submit_user_message(UserMessage::from(
"Steer submitted while /review was running.".to_string(),
));
chat.handle_codex_event(Event {
id: "steer-rejected".into(),
msg: EventMsg::Error(ErrorEvent {
message: "cannot steer a review turn".to_string(),
codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable {
turn_kind: NonSteerableTurnKind::Review,
}),
handle_error(
&mut chat,
"cannot steer a review turn",
Some(CodexErrorInfo::ActiveTurnNotSteerable {
turn_kind: NonSteerableTurnKind::Review,
}),
});
);
let width: u16 = 80;
let height: u16 = 18;
@@ -1,12 +1,16 @@
use super::*;
use pretty_assertions::assert_eq;
fn turn_complete_event(turn_id: &str, last_agent_message: Option<&str>) -> TurnCompleteEvent {
serde_json::from_value(serde_json::json!({
"turn_id": turn_id,
"last_agent_message": last_agent_message,
}))
.expect("turn complete event should deserialize")
fn complete_turn_with_message(chat: &mut ChatWidget, turn_id: &str, message: Option<&str>) {
if let Some(message) = message {
complete_assistant_message(
chat,
&format!("{turn_id}-message"),
message,
Some(MessagePhase::FinalAnswer),
);
}
handle_turn_completed(chat, turn_id, /*duration_ms*/ None);
}
fn submit_composer_text(chat: &mut ChatWidget, text: &str) {
@@ -51,7 +55,7 @@ async fn slash_compact_eagerly_queues_follow_up_before_turn_start() {
assert!(chat.bottom_pane.is_task_running());
match rx.try_recv() {
Ok(AppEvent::CodexOp(AppCommand::Compact)) => {}
Ok(AppEvent::CodexOp(Op::Compact)) => {}
other => panic!("expected compact op to be submitted, got {other:?}"),
}
@@ -75,15 +79,7 @@ async fn slash_compact_eagerly_queues_follow_up_before_turn_start() {
async fn queued_slash_compact_dispatches_after_active_turn() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
handle_turn_started(&mut chat, "turn-1");
queue_composer_text_with_tab(&mut chat, "/compact");
@@ -94,16 +90,13 @@ async fn queued_slash_compact_dispatches_after_active_turn() {
);
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
chat.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
});
complete_turn_with_message(&mut chat, "turn-1", Some("done"));
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
assert!(
events
.iter()
.any(|event| matches!(event, AppEvent::CodexOp(AppCommand::Compact))),
.any(|event| matches!(event, AppEvent::CodexOp(Op::Compact))),
"expected queued /compact to submit compact op; events: {events:?}"
);
}
@@ -112,43 +105,26 @@ async fn queued_slash_compact_dispatches_after_active_turn() {
async fn queued_slash_review_with_args_dispatches_after_active_turn() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
handle_turn_started(&mut chat, "turn-1");
queue_composer_text_with_tab(&mut chat, "/review check regressions");
chat.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
});
complete_turn_with_message(&mut chat, "turn-1", Some("done"));
match op_rx.try_recv() {
Ok(Op::AddToHistory { .. }) => match op_rx.try_recv() {
Ok(Op::Review { review_request }) => assert_eq!(
review_request,
ReviewRequest {
target: ReviewTarget::Custom {
instructions: "check regressions".to_string(),
},
user_facing_hint: None,
Ok(Op::Review { target }) => assert_eq!(
target,
ReviewTarget::Custom {
instructions: "check regressions".to_string(),
}
),
other => panic!("expected queued /review to submit review op, got {other:?}"),
},
Ok(Op::Review { review_request }) => assert_eq!(
review_request,
ReviewRequest {
target: ReviewTarget::Custom {
instructions: "check regressions".to_string(),
},
user_facing_hint: None,
Ok(Op::Review { target }) => assert_eq!(
target,
ReviewTarget::Custom {
instructions: "check regressions".to_string(),
}
),
other => panic!("expected queued /review to submit review op, got {other:?}"),
@@ -159,15 +135,7 @@ async fn queued_slash_review_with_args_dispatches_after_active_turn() {
async fn queued_slash_review_with_args_restores_for_edit() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
handle_turn_started(&mut chat, "turn-1");
queue_composer_text_with_tab(&mut chat, "/review check regressions");
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
@@ -182,15 +150,7 @@ async fn queued_slash_review_with_args_restores_for_edit() {
async fn queued_bang_shell_dispatches_after_active_turn() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
handle_turn_started(&mut chat, "turn-1");
queue_composer_text_with_tab(&mut chat, "!echo hi");
@@ -201,10 +161,7 @@ async fn queued_bang_shell_dispatches_after_active_turn() {
);
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
chat.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
});
complete_turn_with_message(&mut chat, "turn-1", Some("done"));
match op_rx.try_recv() {
Ok(Op::RunUserShellCommand { command }) => assert_eq!(command, "echo hi"),
@@ -221,25 +178,14 @@ async fn queued_bang_shell_dispatches_after_active_turn() {
async fn queued_empty_bang_shell_reports_help_when_dequeued_and_drains_next_input() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
handle_turn_started(&mut chat, "turn-1");
queue_composer_text_with_tab(&mut chat, "!");
queue_composer_text_with_tab(&mut chat, "hello after help");
assert!(drain_insert_history(&mut rx).is_empty());
chat.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
});
complete_turn_with_message(&mut chat, "turn-1", Some("done"));
let cells = drain_insert_history(&mut rx);
let rendered = cells
@@ -269,23 +215,12 @@ async fn queued_empty_bang_shell_reports_help_when_dequeued_and_drains_next_inpu
async fn queued_bang_shell_waits_for_user_shell_completion_before_next_input() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
handle_turn_started(&mut chat, "turn-1");
queue_composer_text_with_tab(&mut chat, "!echo hi");
queue_composer_text_with_tab(&mut chat, "hello after shell");
chat.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
});
complete_turn_with_message(&mut chat, "turn-1", Some("done"));
match op_rx.try_recv() {
Ok(Op::RunUserShellCommand { command }) => assert_eq!(command, "echo hi"),
@@ -321,23 +256,12 @@ async fn queued_bang_shell_waits_for_user_shell_completion_before_next_input() {
async fn assert_cancelled_queued_menu_drains_next_input(command: &str, expected_popup_text: &str) {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
handle_turn_started(&mut chat, "turn-1");
queue_composer_text_with_tab(&mut chat, command);
queue_composer_text_with_tab(&mut chat, "hello after menu");
chat.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
});
complete_turn_with_message(&mut chat, "turn-1", Some("done"));
assert_eq!(chat.queued_user_messages.len(), 1);
let popup = render_bottom_popup(&chat, /*width*/ 80);
@@ -373,23 +297,12 @@ async fn queued_slash_menu_cancel_drains_next_input() {
async fn queued_slash_menu_selection_drains_next_input() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
handle_turn_started(&mut chat, "turn-1");
queue_composer_text_with_tab(&mut chat, "/permissions");
queue_composer_text_with_tab(&mut chat, "hello after selection");
chat.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
});
complete_turn_with_message(&mut chat, "turn-1", Some("done"));
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert!(
@@ -417,23 +330,12 @@ async fn queued_bare_rename_drains_next_input_after_name_update() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
handle_turn_started(&mut chat, "turn-1");
queue_composer_text_with_tab(&mut chat, "/rename");
queue_composer_text_with_tab(&mut chat, "hello after rename");
chat.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
});
complete_turn_with_message(&mut chat, "turn-1", Some("done"));
assert_eq!(chat.queued_user_messages.len(), 1);
assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Name thread"));
@@ -446,18 +348,20 @@ async fn queued_bare_rename_drains_next_input_after_name_update() {
assert!(
events.iter().any(|event| matches!(
event,
AppEvent::CodexOp(AppCommand::SetThreadName { name }) if name == "Queued rename"
AppEvent::CodexOp(Op::SetThreadName { name }) if name == "Queued rename"
)),
"expected rename prompt to submit thread name; events: {events:?}"
);
chat.handle_codex_event(Event {
id: "rename".into(),
msg: EventMsg::ThreadNameUpdated(codex_protocol::protocol::ThreadNameUpdatedEvent {
thread_id,
thread_name: Some("Queued rename".to_string()),
}),
});
chat.handle_server_notification(
ServerNotification::ThreadNameUpdated(
codex_app_server_protocol::ThreadNameUpdatedNotification {
thread_id: thread_id.to_string(),
thread_name: Some("Queued rename".to_string()),
},
),
/*replay_kind*/ None,
);
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
@@ -477,30 +381,19 @@ async fn queued_inline_rename_does_not_drain_again_before_turn_started() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
handle_turn_started(&mut chat, "turn-1");
queue_composer_text_with_tab(&mut chat, "/rename Queued rename");
queue_composer_text_with_tab(&mut chat, "first after rename");
queue_composer_text_with_tab(&mut chat, "second after rename");
chat.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
});
complete_turn_with_message(&mut chat, "turn-1", Some("done"));
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
assert!(
events.iter().any(|event| matches!(
event,
AppEvent::CodexOp(AppCommand::SetThreadName { name }) if name == "Queued rename"
AppEvent::CodexOp(Op::SetThreadName { name }) if name == "Queued rename"
)),
"expected queued /rename to submit thread name; events: {events:?}"
);
@@ -534,13 +427,15 @@ async fn queued_inline_rename_does_not_drain_again_before_turn_started() {
vec!["second after rename"]
);
chat.handle_codex_event(Event {
id: "rename".into(),
msg: EventMsg::ThreadNameUpdated(codex_protocol::protocol::ThreadNameUpdatedEvent {
thread_id,
thread_name: Some("Queued rename".to_string()),
}),
});
chat.handle_server_notification(
ServerNotification::ThreadNameUpdated(
codex_app_server_protocol::ThreadNameUpdatedNotification {
thread_id: thread_id.to_string(),
thread_name: Some("Queued rename".to_string()),
},
),
/*replay_kind*/ None,
);
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
assert_eq!(
@@ -548,19 +443,8 @@ async fn queued_inline_rename_does_not_drain_again_before_turn_started() {
vec!["second after rename"]
);
chat.handle_codex_event(Event {
id: "turn-2-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-2".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
chat.handle_codex_event(Event {
id: "turn-2-complete".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-2", Some("done"))),
});
handle_turn_started(&mut chat, "turn-2");
complete_turn_with_message(&mut chat, "turn-2", Some("done"));
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
@@ -579,24 +463,13 @@ async fn queued_inline_rename_does_not_drain_again_before_turn_started() {
async fn queued_unknown_slash_reports_error_when_dequeued() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
handle_turn_started(&mut chat, "turn-1");
queue_composer_text_with_tab(&mut chat, "/does-not-exist");
assert!(drain_insert_history(&mut rx).is_empty());
chat.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
});
complete_turn_with_message(&mut chat, "turn-1", Some("done"));
let cells = drain_insert_history(&mut rx);
let rendered = cells
@@ -1137,7 +1010,7 @@ async fn slash_rename_prefills_existing_thread_name() {
assert_matches!(
rx.try_recv(),
Ok(AppEvent::CodexOp(AppCommand::SetThreadName { name })) if name == "Current project title"
Ok(AppEvent::CodexOp(Op::SetThreadName { name })) if name == "Current project title"
);
}
@@ -1259,16 +1132,7 @@ async fn slash_logout_requests_app_server_logout() {
async fn slash_copy_state_tracks_turn_complete_final_reply() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-1".to_string(),
last_agent_message: Some("Final reply **markdown**".to_string()),
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
}),
});
complete_turn_with_message(&mut chat, "turn-1", Some("Final reply **markdown**"));
assert_eq!(
chat.last_agent_markdown_text(),
@@ -1281,27 +1145,18 @@ async fn slash_copy_state_tracks_plan_item_completion() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let plan_text = "## Plan\n\n1. Build it\n2. Test it".to_string();
chat.handle_codex_event(Event {
id: "item-plan".into(),
msg: EventMsg::ItemCompleted(ItemCompletedEvent {
thread_id: ThreadId::new(),
chat.handle_server_notification(
ServerNotification::ItemCompleted(ItemCompletedNotification {
thread_id: String::new(),
turn_id: "turn-1".to_string(),
item: TurnItem::Plan(PlanItem {
item: AppServerThreadItem::Plan {
id: "plan-1".to_string(),
text: plan_text.clone(),
}),
},
}),
});
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-1".to_string(),
last_agent_message: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
}),
});
/*replay_kind*/ None,
);
handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None);
assert_eq!(chat.last_agent_markdown_text(), Some(plan_text.as_str()));
assert_matches!(
@@ -1438,16 +1293,7 @@ async fn slash_copy_stores_clipboard_lease_and_preserves_it_on_failure() {
async fn slash_copy_state_is_preserved_during_running_task() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-1".to_string(),
last_agent_message: Some("Previous completed reply".to_string()),
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
}),
});
complete_turn_with_message(&mut chat, "turn-1", Some("Previous completed reply"));
chat.on_task_started();
assert_eq!(
@@ -1456,50 +1302,11 @@ async fn slash_copy_state_is_preserved_during_running_task() {
);
}
#[tokio::test]
async fn slash_copy_tracks_replayed_legacy_agent_message_when_turn_complete_omits_text() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_codex_event_replay(Event {
id: "turn-1".into(),
msg: EventMsg::AgentMessage(AgentMessageEvent {
message: "Legacy final message".into(),
phase: None,
memory_citation: None,
}),
});
let _ = drain_insert_history(&mut rx);
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-1".to_string(),
last_agent_message: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
}),
});
let _ = drain_insert_history(&mut rx);
assert_eq!(
chat.last_agent_markdown_text(),
Some("Legacy final message")
);
}
#[tokio::test]
async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
handle_turn_started(&mut chat, "turn-1");
complete_assistant_message(
&mut chat,
"msg-1",
@@ -1507,16 +1314,7 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text(
/*phase*/ None,
);
let _ = drain_insert_history(&mut rx);
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-1".to_string(),
last_agent_message: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
}),
});
handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None);
let _ = drain_insert_history(&mut rx);
assert_eq!(
@@ -1533,18 +1331,10 @@ async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text(
async fn agent_turn_complete_notification_does_not_reuse_stale_copy_source() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("Previous reply"))),
});
complete_turn_with_message(&mut chat, "turn-1", Some("Previous reply"));
chat.pending_notification = None;
chat.handle_codex_event(Event {
id: "turn-2".into(),
msg: EventMsg::TurnComplete(turn_complete_event(
"turn-2", /*last_agent_message*/ None,
)),
});
handle_turn_completed(&mut chat, "turn-2", /*duration_ms*/ None);
assert_matches!(
chat.pending_notification,
@@ -1576,10 +1366,7 @@ async fn active_goal_without_follow_up_suppresses_agent_turn_complete_notificati
/*replay_kind*/ None,
);
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("Still working"))),
});
complete_turn_with_message(&mut chat, "turn-1", Some("Still working"));
assert_matches!(chat.pending_notification, None);
}
@@ -1588,21 +1375,10 @@ async fn active_goal_without_follow_up_suppresses_agent_turn_complete_notificati
async fn queued_follow_up_suppresses_agent_turn_complete_notification() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
handle_turn_started(&mut chat, "turn-1");
chat.queue_user_message("Continue".into());
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("Still working"))),
});
complete_turn_with_message(&mut chat, "turn-1", Some("Still working"));
assert_matches!(chat.pending_notification, None);
assert!(chat.queued_user_messages.is_empty());
@@ -1613,21 +1389,10 @@ async fn queued_follow_up_suppresses_agent_turn_complete_notification() {
async fn queued_menu_slash_keeps_agent_turn_complete_notification() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
handle_turn_started(&mut chat, "turn-1");
queue_composer_text_with_tab(&mut chat, "/model");
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("Done"))),
});
complete_turn_with_message(&mut chat, "turn-1", Some("Done"));
assert_matches!(
chat.pending_notification,
@@ -1641,40 +1406,20 @@ async fn queued_menu_slash_keeps_agent_turn_complete_notification() {
async fn slash_copy_uses_latest_surviving_response_after_rollback() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_codex_event_replay(Event {
id: "user-1".into(),
msg: EventMsg::UserMessage(UserMessageEvent {
message: "foo".to_string(),
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
}),
});
chat.handle_codex_event_replay(Event {
id: "agent-1".into(),
msg: EventMsg::AgentMessage(AgentMessageEvent {
message: "foo response".to_string(),
phase: None,
memory_citation: None,
}),
});
chat.handle_codex_event_replay(Event {
id: "user-2".into(),
msg: EventMsg::UserMessage(UserMessageEvent {
message: "bar".to_string(),
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
}),
});
chat.handle_codex_event_replay(Event {
id: "agent-2".into(),
msg: EventMsg::AgentMessage(AgentMessageEvent {
message: "bar response".to_string(),
phase: None,
memory_citation: None,
}),
});
replay_user_message_text(&mut chat, "user-1", "foo", ReplayKind::ThreadSnapshot);
replay_agent_message(
&mut chat,
"agent-1",
"foo response",
ReplayKind::ThreadSnapshot,
);
replay_user_message_text(&mut chat, "user-2", "bar", ReplayKind::ThreadSnapshot);
replay_agent_message(
&mut chat,
"agent-2",
"bar response",
ReplayKind::ThreadSnapshot,
);
let _ = drain_insert_history(&mut rx);
assert_eq!(chat.last_agent_markdown_text(), Some("bar response"));
@@ -1691,23 +1436,13 @@ async fn slash_copy_uses_latest_surviving_response_after_rollback() {
async fn slash_copy_reports_when_rewind_exceeds_retained_copy_history() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_codex_event_replay(Event {
id: "user-1".into(),
msg: EventMsg::UserMessage(UserMessageEvent {
message: "foo".to_string(),
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
}),
});
chat.handle_codex_event_replay(Event {
id: "agent-1".into(),
msg: EventMsg::AgentMessage(AgentMessageEvent {
message: "foo response".to_string(),
phase: None,
memory_citation: None,
}),
});
replay_user_message_text(&mut chat, "user-1", "foo", ReplayKind::ThreadSnapshot);
replay_agent_message(
&mut chat,
"agent-1",
"foo response",
ReplayKind::ThreadSnapshot,
);
let _ = drain_insert_history(&mut rx);
chat.truncate_agent_copy_history_to_user_turn_count(/*user_turn_count*/ 0);
@@ -1954,97 +1689,6 @@ async fn slash_rollout_handles_missing_path() {
);
}
#[tokio::test]
async fn undo_success_events_render_info_messages() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_codex_event(Event {
id: "turn-1".to_string(),
msg: EventMsg::UndoStarted(UndoStartedEvent {
message: Some("Undo requested for the last turn...".to_string()),
}),
});
assert!(
chat.bottom_pane.status_indicator_visible(),
"status indicator should be visible during undo"
);
chat.handle_codex_event(Event {
id: "turn-1".to_string(),
msg: EventMsg::UndoCompleted(UndoCompletedEvent {
success: true,
message: None,
}),
});
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected final status only");
assert!(
!chat.bottom_pane.status_indicator_visible(),
"status indicator should be hidden after successful undo"
);
let completed = lines_to_single_string(&cells[0]);
assert!(
completed.contains("Undo completed successfully."),
"expected default success message, got {completed:?}"
);
}
#[tokio::test]
async fn undo_failure_events_render_error_message() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_codex_event(Event {
id: "turn-2".to_string(),
msg: EventMsg::UndoStarted(UndoStartedEvent { message: None }),
});
assert!(
chat.bottom_pane.status_indicator_visible(),
"status indicator should be visible during undo"
);
chat.handle_codex_event(Event {
id: "turn-2".to_string(),
msg: EventMsg::UndoCompleted(UndoCompletedEvent {
success: false,
message: Some("Failed to restore workspace state.".to_string()),
}),
});
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected final status only");
assert!(
!chat.bottom_pane.status_indicator_visible(),
"status indicator should be hidden after failed undo"
);
let completed = lines_to_single_string(&cells[0]);
assert!(
completed.contains("Failed to restore workspace state."),
"expected failure message, got {completed:?}"
);
}
#[tokio::test]
async fn undo_started_hides_interrupt_hint() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_codex_event(Event {
id: "turn-hint".to_string(),
msg: EventMsg::UndoStarted(UndoStartedEvent { message: None }),
});
let status = chat
.bottom_pane
.status_widget()
.expect("status indicator should be active");
assert!(
!status.interrupt_hint_visible(),
"undo should hide the interrupt hint because the operation cannot be cancelled"
);
}
#[tokio::test]
async fn fast_slash_command_updates_and_persists_local_service_tier() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
@@ -2056,7 +1700,7 @@ async fn fast_slash_command_updates_and_persists_local_service_tier() {
assert!(
events.iter().any(|event| matches!(
event,
AppEvent::CodexOp(AppCommand::OverrideTurnContext {
AppEvent::CodexOp(Op::OverrideTurnContext {
service_tier: Some(Some(ServiceTier::Fast)),
..
})
@@ -2106,29 +1750,18 @@ async fn queued_fast_slash_applies_before_next_queued_message() {
chat.thread_id = Some(ThreadId::new());
set_chatgpt_auth(&mut chat);
chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true);
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
handle_turn_started(&mut chat, "turn-1");
queue_composer_text_with_tab(&mut chat, "/fast on");
queue_composer_text_with_tab(&mut chat, "hello after fast");
chat.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
});
complete_turn_with_message(&mut chat, "turn-1", Some("done"));
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
assert!(
events.iter().any(|event| matches!(
event,
AppEvent::CodexOp(AppCommand::OverrideTurnContext {
AppEvent::CodexOp(Op::OverrideTurnContext {
service_tier: Some(Some(ServiceTier::Fast)),
..
})
@@ -2167,7 +1800,7 @@ async fn user_turn_sends_standard_override_after_fast_is_turned_off() {
assert!(
events.iter().any(|event| matches!(
event,
AppEvent::CodexOp(AppCommand::OverrideTurnContext {
AppEvent::CodexOp(Op::OverrideTurnContext {
service_tier: Some(None),
..
})
@@ -2199,28 +1832,18 @@ async fn user_turn_sends_standard_override_after_fast_is_turned_off() {
async fn compact_queues_user_messages_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.handle_codex_event(Event {
id: "turn-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
handle_turn_started(&mut chat, "turn-1");
chat.submit_user_message(UserMessage::from(
"Steer submitted while /compact was running.".to_string(),
));
chat.handle_codex_event(Event {
id: "steer-rejected".into(),
msg: EventMsg::Error(ErrorEvent {
message: "cannot steer a compact turn".to_string(),
codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable {
turn_kind: NonSteerableTurnKind::Compact,
}),
handle_error(
&mut chat,
"cannot steer a compact turn",
Some(CodexErrorInfo::ActiveTurnNotSteerable {
turn_kind: NonSteerableTurnKind::Compact,
}),
});
);
let width: u16 = 80;
let height: u16 = 18;
File diff suppressed because it is too large Load Diff
@@ -21,12 +21,8 @@ async fn terminal_title_shows_action_required_while_exec_approval_is_pending() {
proposed_network_policy_amendments: None,
additional_permissions: None,
available_decisions: None,
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
id: "sub-action-required".into(),
msg: EventMsg::ExecApprovalRequest(request),
});
handle_exec_approval_request(&mut chat, "sub-action-required", request);
chat.pre_draw_tick();
@@ -67,12 +63,8 @@ async fn terminal_title_action_required_respects_spinner_setting() {
proposed_network_policy_amendments: None,
additional_permissions: None,
available_decisions: None,
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
id: "sub-no-spinner".into(),
msg: EventMsg::ExecApprovalRequest(request),
});
handle_exec_approval_request(&mut chat, "sub-no-spinner", request);
chat.pre_draw_tick();
@@ -99,12 +91,8 @@ async fn terminal_title_action_required_blinks_when_animations_are_enabled() {
proposed_network_policy_amendments: None,
additional_permissions: None,
available_decisions: None,
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
id: "sub-blink".into(),
msg: EventMsg::ExecApprovalRequest(request),
});
handle_exec_approval_request(&mut chat, "sub-blink", request);
chat.pre_draw_tick();
@@ -138,12 +126,8 @@ async fn terminal_title_activity_indicators_do_not_animate_when_animations_are_d
proposed_network_policy_amendments: None,
additional_permissions: None,
available_decisions: None,
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
id: "sub-no-animations".into(),
msg: EventMsg::ExecApprovalRequest(request),
});
handle_exec_approval_request(&mut chat, "sub-no-animations", request);
chat.pre_draw_tick();