Remove legacy ChatWidget test event shims

This commit is contained in:
Eric Traut
2026-04-29 01:09:31 -07:00
parent c6d618d82b
commit a5d018ead5
29 changed files with 1794 additions and 3361 deletions
+6 -10
View File
@@ -547,9 +547,6 @@ mod tests {
use super::*;
use crate::app::test_support::app_enabled_in_effective_config;
use crate::app::test_support::make_test_app;
use crate::chatwidget::test_events::Event;
use crate::chatwidget::test_events::EventMsg;
use crate::chatwidget::test_events::SessionConfiguredEvent;
use crate::test_support::PathBufExt;
use codex_protocol::models::PermissionProfile;
use pretty_assertions::assert_eq;
@@ -635,11 +632,11 @@ mod tests {
let next_cwd_tmp = tempdir()?;
let next_cwd = next_cwd_tmp.path().to_path_buf();
app.chat_widget.handle_codex_event(Event {
id: String::new(),
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
session_id: ThreadId::new(),
app.chat_widget
.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(),
@@ -648,14 +645,13 @@ mod tests {
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: PermissionProfile::read_only(),
cwd: next_cwd.clone().abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
}),
});
});
assert_eq!(app.chat_widget.config_ref().cwd.to_path_buf(), next_cwd);
assert_eq!(app.config.cwd, original_cwd);
@@ -32,9 +32,9 @@ impl ElicitationRequestKey {
// - buffer eviction (`note_evicted_event`)
//
// We keep both fast lookup sets (for snapshot filtering by call_id/request key) and
// turn-indexed queues/vectors so `TurnComplete`/`TurnAborted` can clear stale prompts tied
// to a turn. `request_user_input` removal is FIFO because the overlay answers queued prompts
// in FIFO order for a shared `turn_id`.
// turn-indexed queues/vectors so turn completion or interruption can clear
// stale prompts tied to a turn. `request_user_input` removal is FIFO because
// the overlay answers queued prompts in FIFO order for a shared `turn_id`.
pub(super) struct PendingInteractiveReplayState {
exec_approval_call_ids: HashSet<String>,
exec_approval_call_ids_by_turn_id: HashMap<String, Vec<String>>,
+26 -31
View File
@@ -22,9 +22,6 @@ use crate::multi_agents::AgentPickerThreadEntry;
use assert_matches::assert_matches;
use crate::app_command::AppCommand as Op;
use crate::chatwidget::test_events::Event;
use crate::chatwidget::test_events::EventMsg;
use crate::chatwidget::test_events::SessionConfiguredEvent;
use crate::diff_model::FileChange;
use crate::legacy_core::config::ConfigBuilder;
use crate::legacy_core::config::ConfigOverrides;
@@ -3176,6 +3173,7 @@ async fn side_thread_snapshot_hides_forked_parent_transcript() {
let mut store = ThreadEventStore::new(/*capacity*/ 4);
let session = ThreadSessionState {
forked_from_id: Some(parent_thread_id),
fork_parent_title: None,
..test_thread_session(side_thread_id, test_path_buf("/tmp/side"))
};
let parent_turn = test_turn(
@@ -3238,6 +3236,7 @@ async fn side_thread_snapshot_skips_session_header_preamble() {
let snapshot = ThreadEventSnapshot {
session: Some(ThreadSessionState {
forked_from_id: Some(parent_thread_id),
fork_parent_title: None,
..test_thread_session(side_thread_id, test_path_buf("/tmp/side"))
}),
turns: Vec::new(),
@@ -4350,11 +4349,11 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() {
assert_eq!(user_count(&app.transcript_cells), 2);
let base_id = ThreadId::new();
app.chat_widget.handle_codex_event(Event {
id: String::new(),
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
session_id: base_id,
app.chat_widget
.handle_thread_session(crate::session_state::ThreadSessionState {
thread_id: base_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "gpt-test".to_string(),
model_provider_id: "test-provider".to_string(),
@@ -4363,14 +4362,13 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() {
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: PermissionProfile::read_only(),
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
}),
});
});
app.backtrack.base_id = Some(base_id);
app.backtrack.primed = true;
@@ -4443,11 +4441,11 @@ async fn backtrack_resubmit_preserves_data_image_urls_in_user_turn() {
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
app.chat_widget.handle_codex_event(Event {
id: String::new(),
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
session_id: thread_id,
app.chat_widget
.handle_thread_session(crate::session_state::ThreadSessionState {
thread_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "gpt-test".to_string(),
model_provider_id: "test-provider".to_string(),
@@ -4456,14 +4454,13 @@ async fn backtrack_resubmit_preserves_data_image_urls_in_user_turn() {
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: PermissionProfile::read_only(),
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
}),
});
});
let data_image_url = "data:image/png;base64,abc123".to_string();
app.transcript_cells = vec![Arc::new(UserHistoryCell {
@@ -4684,6 +4681,7 @@ async fn refreshed_snapshot_session_persists_resumed_turns() {
)];
let resumed_session = ThreadSessionState {
cwd: test_path_buf("/tmp/refreshed").abs(),
instruction_source_paths: Vec::new(),
..initial_session.clone()
};
let mut snapshot = ThreadEventSnapshot {
@@ -4831,9 +4829,10 @@ async fn new_session_requests_shutdown_for_previous_conversation() {
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
let event = SessionConfiguredEvent {
session_id: thread_id,
let event = crate::session_state::ThreadSessionState {
thread_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "gpt-test".to_string(),
model_provider_id: "test-provider".to_string(),
@@ -4842,18 +4841,15 @@ async fn new_session_requests_shutdown_for_previous_conversation() {
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: PermissionProfile::read_only(),
cwd: test_path_buf("/home/user/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
};
app.chat_widget.handle_codex_event(Event {
id: String::new(),
msg: EventMsg::SessionConfigured(event),
});
app.chat_widget.handle_thread_session(event);
while app_event_rx.try_recv().is_ok() {}
while op_rx.try_recv().is_ok() {}
@@ -4941,11 +4937,11 @@ async fn interrupt_without_active_turn_is_treated_as_handled() {
async fn clear_only_ui_reset_preserves_chat_session_state() {
let mut app = make_test_app().await;
let thread_id = ThreadId::new();
app.chat_widget.handle_codex_event(Event {
id: String::new(),
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
session_id: thread_id,
app.chat_widget
.handle_thread_session(crate::session_state::ThreadSessionState {
thread_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: Some("keep me".to_string()),
model: "gpt-test".to_string(),
model_provider_id: "test-provider".to_string(),
@@ -4954,14 +4950,13 @@ async fn clear_only_ui_reset_preserves_chat_session_state() {
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: PermissionProfile::read_only(),
cwd: test_path_buf("/tmp/project").abs(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
}),
});
});
app.chat_widget
.apply_external_edit("draft prompt".to_string());
app.transcript_cells = vec![Arc::new(UserHistoryCell {
+2 -2
View File
@@ -497,8 +497,8 @@ impl App {
self.backtrack.pending_rollback = None;
}
/// Apply rollback semantics for `ThreadRolledBack` events where this TUI does not have an
/// in-flight backtrack request (`pending_rollback` is `None`).
/// Apply rollback semantics for a confirmed rollback where this TUI does
/// not have an in-flight backtrack request (`pending_rollback` is `None`).
///
/// Returns `true` when local transcript state changed.
pub(crate) fn apply_non_pending_thread_rollback(&mut self, num_turns: u32) -> bool {
+5 -485
View File
@@ -42,44 +42,6 @@ use std::sync::atomic::Ordering;
use std::time::Duration;
use std::time::Instant;
#[cfg(test)]
use self::test_events::AgentMessageDeltaEvent;
#[cfg(test)]
use self::test_events::AgentMessageEvent;
#[cfg(test)]
use self::test_events::AgentReasoningDeltaEvent;
#[cfg(test)]
use self::test_events::AgentReasoningEvent;
#[cfg(test)]
use self::test_events::AgentReasoningRawContentDeltaEvent;
#[cfg(test)]
use self::test_events::AgentReasoningRawContentEvent;
#[cfg(test)]
use self::test_events::BackgroundEventEvent;
#[cfg(test)]
use self::test_events::ErrorEvent;
#[cfg(test)]
use self::test_events::Event;
#[cfg(test)]
use self::test_events::EventMsg;
#[cfg(test)]
use self::test_events::ExitedReviewModeEvent;
#[cfg(test)]
use self::test_events::McpListToolsResponseEvent;
#[cfg(test)]
use self::test_events::SessionConfiguredEvent;
#[cfg(test)]
use self::test_events::StreamErrorEvent;
#[cfg(test)]
use self::test_events::TurnCompleteEvent;
#[cfg(test)]
use self::test_events::TurnDiffEvent;
#[cfg(test)]
use self::test_events::UndoCompletedEvent;
#[cfg(test)]
use self::test_events::UndoStartedEvent;
#[cfg(test)]
use self::test_events::WarningEvent;
use crate::app::app_server_requests::ResolvedAppServerRequest;
use crate::app_command::AppCommand;
use crate::app_event::HistoryLookupResponse;
@@ -143,8 +105,6 @@ use codex_app_server_protocol::AddCreditsNudgeCreditType;
use codex_app_server_protocol::AddCreditsNudgeEmailStatus;
use codex_app_server_protocol::AppInfo;
use codex_app_server_protocol::AppSummary;
#[cfg(test)]
use codex_app_server_protocol::CodexErrorInfo as CoreCodexErrorInfo;
use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo;
use codex_app_server_protocol::CollabAgentTool;
use codex_app_server_protocol::CollabAgentToolCallStatus;
@@ -158,8 +118,6 @@ use codex_app_server_protocol::GuardianApprovalReviewAction;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::ItemStartedNotification;
use codex_app_server_protocol::McpServerStatusDetail;
#[cfg(test)]
use codex_app_server_protocol::ModelVerification as CoreModelVerification;
use codex_app_server_protocol::ModelVerification as AppServerModelVerification;
use codex_app_server_protocol::RateLimitReachedType;
use codex_app_server_protocol::RateLimitSnapshot;
@@ -649,18 +607,6 @@ enum RateLimitErrorKind {
Generic,
}
#[cfg(test)]
fn core_rate_limit_error_kind(info: &CoreCodexErrorInfo) -> Option<RateLimitErrorKind> {
match info {
CoreCodexErrorInfo::ServerOverloaded => Some(RateLimitErrorKind::ServerOverloaded),
CoreCodexErrorInfo::UsageLimitExceeded => Some(RateLimitErrorKind::UsageLimit),
CoreCodexErrorInfo::ResponseTooManyFailedAttempts {
http_status_code: Some(429),
} => Some(RateLimitErrorKind::Generic),
_ => None,
}
}
fn app_server_rate_limit_error_kind(info: &AppServerCodexErrorInfo) -> Option<RateLimitErrorKind> {
match info {
AppServerCodexErrorInfo::ServerOverloaded => Some(RateLimitErrorKind::ServerOverloaded),
@@ -672,11 +618,6 @@ fn app_server_rate_limit_error_kind(info: &AppServerCodexErrorInfo) -> Option<Ra
}
}
#[cfg(test)]
fn is_core_cyber_policy_error(info: &CoreCodexErrorInfo) -> bool {
matches!(info, CoreCodexErrorInfo::CyberPolicy)
}
fn is_app_server_cyber_policy_error(info: &AppServerCodexErrorInfo) -> bool {
matches!(info, AppServerCodexErrorInfo::CyberPolicy)
}
@@ -2013,15 +1954,7 @@ impl ChatWidget {
.iter()
.any(|item| item == "run-state" || item == "status")
});
let title_uses_activity = self.config.tui_terminal_title.as_ref().is_none_or(|items| {
items
.iter()
.any(|item| item == "activity" || item == "spinner")
});
if title_uses_status
|| (title_uses_activity
&& self.terminal_title_status_kind == TerminalTitleStatusKind::Undoing)
{
if title_uses_status {
self.refresh_status_surfaces();
}
}
@@ -2199,23 +2132,11 @@ impl ChatWidget {
}
// --- Small event handlers ---
#[cfg(test)]
fn on_session_configured(&mut self, event: SessionConfiguredEvent) {
let (session, initial_messages) = event.into_session();
self.on_session_configured_with_display_and_fork_parent_title(
session,
SessionConfiguredDisplay::Normal,
/*fork_parent_title*/ None,
initial_messages,
);
}
fn on_session_configured_with_display_and_fork_parent_title(
&mut self,
session: ThreadSessionState,
display: SessionConfiguredDisplay,
fork_parent_title: Option<String>,
#[cfg(test)] initial_messages: Option<Vec<EventMsg>>,
) {
self.last_agent_markdown = None;
self.agent_turn_markdowns.clear();
@@ -2300,11 +2221,6 @@ impl ChatWidget {
show_fast_status,
);
self.apply_session_info_cell(session_info_cell);
#[cfg(test)]
if let Some(messages) = initial_messages {
self.replay_initial_messages(messages);
}
} else if self
.active_cell
.as_ref()
@@ -2352,9 +2268,6 @@ impl ChatWidget {
session,
SessionConfiguredDisplay::Normal,
fork_parent_title,
#[cfg(test)]
/*initial_messages*/
None,
);
}
@@ -2364,9 +2277,6 @@ impl ChatWidget {
session,
SessionConfiguredDisplay::Quiet,
/*fork_parent_title*/ None,
#[cfg(test)]
/*initial_messages*/
None,
);
}
@@ -2377,9 +2287,6 @@ impl ChatWidget {
session,
SessionConfiguredDisplay::SideConversation,
fork_parent_title,
#[cfg(test)]
/*initial_messages*/
None,
);
}
@@ -2496,11 +2403,6 @@ impl ChatWidget {
self.request_redraw();
}
#[cfg(test)]
fn on_agent_message(&mut self, message: String) {
self.finalize_completed_assistant_message(Some(&message));
}
fn on_agent_message_delta(&mut self, delta: String) {
self.handle_streaming_delta(delta);
}
@@ -2917,14 +2819,6 @@ impl ChatWidget {
true
}
#[cfg(test)]
fn handle_steer_rejected_error(&mut self, codex_error_info: &CoreCodexErrorInfo) -> bool {
matches!(
codex_error_info,
CoreCodexErrorInfo::ActiveTurnNotSteerable { .. }
) && self.enqueue_rejected_steer()
}
fn handle_app_server_steer_rejected_error(
&mut self,
codex_error_info: &AppServerCodexErrorInfo,
@@ -3036,28 +2930,6 @@ impl ChatWidget {
}
}
#[cfg(test)]
fn apply_turn_started_context_window(&mut self, model_context_window: Option<i64>) {
let info = match self.token_info.take() {
Some(mut info) => {
info.model_context_window = model_context_window;
info
}
None => {
let Some(model_context_window) = model_context_window else {
return;
};
TokenUsageInfo {
total_token_usage: TokenUsage::default(),
last_token_usage: TokenUsage::default(),
model_context_window: Some(model_context_window),
}
}
};
self.apply_token_info(info);
}
fn apply_token_info(&mut self, info: TokenUsageInfo) {
let percent = self.context_remaining_percent(&info);
let used_tokens = self.context_used_tokens(&info, percent.is_some());
@@ -3344,13 +3216,6 @@ impl ChatWidget {
self.request_redraw();
}
#[cfg(test)]
fn on_core_model_verification(&mut self, verifications: &[CoreModelVerification]) {
if verifications.contains(&CoreModelVerification::TrustedAccessForCyber) {
self.on_warning(TRUSTED_ACCESS_FOR_CYBER_VERIFICATION_WARNING);
}
}
fn on_app_server_model_verification(&mut self, verifications: &[AppServerModelVerification]) {
if verifications.contains(&AppServerModelVerification::TrustedAccessForCyber) {
self.on_warning(TRUSTED_ACCESS_FOR_CYBER_VERIFICATION_WARNING);
@@ -3366,9 +3231,7 @@ impl ChatWidget {
self.finalize_turn();
let send_pending_steers_immediately = self.submit_pending_steers_after_interrupt;
self.submit_pending_steers_after_interrupt = false;
if reason != TurnAbortReason::ReviewEnded
&& self.interrupted_turn_notice_mode != InterruptedTurnNoticeMode::Suppress
{
if self.interrupted_turn_notice_mode != InterruptedTurnNoticeMode::Suppress {
if send_pending_steers_immediately {
self.add_to_history(history_cell::new_info_event(
"Model interrupted to submit steer instructions.".to_owned(),
@@ -3381,8 +3244,9 @@ impl ChatWidget {
}
}
// Core clears pending_input before emitting TurnAborted, so any unacknowledged steers
// still tracked here must be restored locally instead of waiting for a later commit.
// The server has already discarded pending input by the time the
// interrupted turn reaches the UI, so any unacknowledged steers still
// tracked here must be restored locally instead of waiting for a later commit.
if send_pending_steers_immediately {
let pending_steers = self
.pending_steers
@@ -4226,16 +4090,6 @@ impl ChatWidget {
self.request_redraw();
}
#[cfg(test)]
fn on_background_event(&mut self, message: String) {
debug!("BackgroundEvent: {message}");
self.bottom_pane.ensure_status_indicator();
self.bottom_pane
.set_interrupt_hint_visible(/*visible*/ true);
self.terminal_title_status_kind = TerminalTitleStatusKind::Thinking;
self.set_status_header(message);
}
fn on_hook_started(&mut self, event: HookStartedEvent) {
self.flush_answer_stream_with_separator();
self.flush_completed_hook_output();
@@ -4358,38 +4212,6 @@ impl ChatWidget {
self.frame_requester.schedule_frame_in(delay);
}
#[cfg(test)]
fn on_undo_started(&mut self, event: UndoStartedEvent) {
self.bottom_pane.ensure_status_indicator();
self.bottom_pane
.set_interrupt_hint_visible(/*visible*/ false);
let message = event
.message
.unwrap_or_else(|| "Undo in progress...".to_string());
self.terminal_title_status_kind = TerminalTitleStatusKind::Undoing;
self.set_status_header(message);
}
#[cfg(test)]
fn on_undo_completed(&mut self, event: UndoCompletedEvent) {
let UndoCompletedEvent { success, message } = event;
self.bottom_pane.hide_status_indicator();
self.terminal_title_status_kind = TerminalTitleStatusKind::Working;
self.refresh_terminal_title();
let message = message.unwrap_or_else(|| {
if success {
"Undo completed successfully.".to_string()
} else {
"Undo failed.".to_string()
}
});
if success {
self.add_info_message(message, /*hint*/ None);
} else {
self.add_error_message(message);
}
}
fn on_stream_error(&mut self, message: String, additional_details: Option<String>) {
if self.retry_status_header.is_none() {
self.retry_status_header = Some(self.current_status.header.clone());
@@ -6965,289 +6787,6 @@ impl ChatWidget {
});
}
#[cfg(test)]
fn replay_initial_messages(&mut self, events: Vec<EventMsg>) {
for msg in events {
if matches!(
msg,
EventMsg::SessionConfigured(_) | EventMsg::ThreadNameUpdated(_)
) {
continue;
}
// `id: None` indicates a synthetic/fake id coming from replay.
self.dispatch_event_msg(
/*id*/ None,
msg,
Some(ReplayKind::ResumeInitialMessages),
);
}
}
#[cfg(test)]
pub(crate) fn handle_codex_event(&mut self, event: Event) {
let Event { id, msg } = event;
self.dispatch_event_msg(Some(id), msg, /*replay_kind*/ None);
}
#[cfg(test)]
pub(crate) fn handle_codex_event_replay(&mut self, event: Event) {
let Event { msg, .. } = event;
if matches!(msg, EventMsg::ShutdownComplete) {
return;
}
self.dispatch_event_msg(/*id*/ None, msg, Some(ReplayKind::ThreadSnapshot));
}
/// Dispatch a test-only event message to the appropriate handler.
///
/// `id` is `Some` for live events and `None` for replayed events from
/// `replay_initial_messages()`. Callers should treat `None` as a "fake" id
/// that must not be used to correlate follow-up actions.
#[cfg(test)]
fn dispatch_event_msg(
&mut self,
id: Option<String>,
msg: EventMsg,
replay_kind: Option<ReplayKind>,
) {
let from_replay = replay_kind.is_some();
let is_resume_initial_replay =
matches!(replay_kind, Some(ReplayKind::ResumeInitialMessages));
let is_stream_error = matches!(&msg, EventMsg::StreamError(_));
if !is_resume_initial_replay && !is_stream_error {
self.restore_retry_status_header_if_present();
}
match msg {
EventMsg::AgentMessageDelta(_)
| EventMsg::PlanDelta(_)
| EventMsg::AgentReasoningDelta(_)
| EventMsg::TerminalInteraction(_)
| EventMsg::ExecCommandOutputDelta(_) => {}
_ => {
tracing::trace!("handle_codex_event: {:?}", msg);
}
}
match msg {
EventMsg::SessionConfigured(e) => self.on_session_configured(e),
EventMsg::ThreadNameUpdated(e) => {
self.on_thread_name_updated(e.thread_id, e.thread_name)
}
// NOTE: All three AgentMessage arms feed `record_agent_markdown` even
// when the message is otherwise not rendered (thread-snapshot replay,
// non-review live messages). This ensures the copy source stays
// populated across replay, resume, and live paths.
EventMsg::AgentMessage(AgentMessageEvent { message, .. })
if matches!(replay_kind, Some(ReplayKind::ThreadSnapshot))
&& !self.is_review_mode =>
{
if !message.is_empty() {
self.record_agent_markdown(&message);
}
}
EventMsg::AgentMessage(AgentMessageEvent { message, .. })
if from_replay || self.is_review_mode =>
{
if !message.is_empty() {
self.record_agent_markdown(&message);
}
// TODO(ccunningham): stop relying on legacy AgentMessage in review mode,
// including thread-snapshot replay, and forward
// ItemCompleted(TurnItem::AgentMessage(_)) instead.
self.on_agent_message(message)
}
EventMsg::AgentMessage(AgentMessageEvent { message, .. }) => {
if !message.is_empty() {
self.record_agent_markdown(&message);
}
}
EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {
self.on_agent_message_delta(delta)
}
EventMsg::PlanDelta(event) => self.on_plan_delta(event.delta),
EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta })
| EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent {
delta,
}) => self.on_agent_reasoning_delta(delta),
EventMsg::AgentReasoning(AgentReasoningEvent { .. }) => self.on_agent_reasoning_final(),
EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => {
self.on_agent_reasoning_delta(text);
self.on_agent_reasoning_final();
}
EventMsg::TurnStarted(event) => {
let turn_id = event.turn_id;
let model_context_window = event.model_context_window;
self.last_turn_id = Some(turn_id);
if !is_resume_initial_replay {
self.apply_turn_started_context_window(model_context_window);
self.on_task_started();
}
}
EventMsg::TurnComplete(TurnCompleteEvent {
last_agent_message,
duration_ms,
..
}) => {
self.on_task_complete(last_agent_message, duration_ms, from_replay);
}
EventMsg::TokenCount(ev) => {
self.set_token_info(ev.info);
self.on_rate_limit_snapshot(ev.rate_limits);
}
EventMsg::Warning(WarningEvent { message })
| EventMsg::GuardianWarning(WarningEvent { message }) => self.on_warning(message),
EventMsg::GuardianAssessment(ev) => self.on_guardian_assessment(ev),
EventMsg::ModelVerification(event) => {
self.on_core_model_verification(&event.verifications)
}
EventMsg::Error(ErrorEvent {
message,
codex_error_info,
}) => {
if codex_error_info
.as_ref()
.is_some_and(|info| self.handle_steer_rejected_error(info))
{
} else if codex_error_info
.as_ref()
.is_some_and(is_core_cyber_policy_error)
{
self.on_cyber_policy_error();
} else if let Some(kind) = codex_error_info
.as_ref()
.and_then(core_rate_limit_error_kind)
{
match kind {
RateLimitErrorKind::ServerOverloaded => {
self.on_server_overloaded_error(message)
}
RateLimitErrorKind::UsageLimit | RateLimitErrorKind::Generic => {
self.on_rate_limit_error(kind, message)
}
}
} else {
self.on_error(message);
}
}
EventMsg::TurnAborted(ev) => match ev.reason {
TurnAbortReason::Interrupted => {
let reason = if ev
.turn_id
.as_deref()
.is_some_and(|turn_id| self.budget_limited_turn_ids.remove(turn_id))
{
TurnAbortReason::BudgetLimited
} else {
ev.reason
};
self.on_interrupted_turn(reason);
}
TurnAbortReason::Replaced => {
self.submit_pending_steers_after_interrupt = false;
self.pending_steers.clear();
self.refresh_pending_input_preview();
self.on_error("Turn aborted: replaced by a new task".to_owned())
}
TurnAbortReason::ReviewEnded => {
self.on_interrupted_turn(ev.reason);
}
TurnAbortReason::BudgetLimited => {
if let Some(turn_id) = ev.turn_id.as_deref() {
self.budget_limited_turn_ids.remove(turn_id);
}
self.on_interrupted_turn(ev.reason);
}
},
EventMsg::PlanUpdate(update) => self.on_plan_update(update),
EventMsg::ExecApprovalRequest(ev) => {
// For replayed events, synthesize an empty id (these should not occur).
self.on_exec_approval_request(id.unwrap_or_default(), ev)
}
EventMsg::ApplyPatchApprovalRequest(ev) => {
self.on_apply_patch_approval_request(id.unwrap_or_default(), ev)
}
EventMsg::ElicitationRequest(ev) => {
self.on_elicitation_request(ev);
}
EventMsg::RequestUserInput(ev) => {
self.on_request_user_input(ev);
}
EventMsg::RequestPermissions(ev) => {
self.on_request_permissions(ev);
}
EventMsg::ExecCommandBegin(ev) => self.on_exec_command_begin(ev),
EventMsg::TerminalInteraction(delta) => self.on_terminal_interaction(delta),
EventMsg::ExecCommandOutputDelta(delta) => self.on_exec_command_output_delta(delta),
EventMsg::PatchApplyBegin(ev) => self.on_patch_apply_begin(ev),
EventMsg::PatchApplyEnd(ev) => self.on_patch_apply_end(ev),
EventMsg::ExecCommandEnd(ev) => self.on_exec_command_end(ev),
EventMsg::ViewImageToolCall(ev) => self.on_view_image_tool_call(ev),
EventMsg::ImageGenerationBegin(ev) => self.on_image_generation_begin(ev),
EventMsg::ImageGenerationEnd(ev) => self.on_image_generation_end(ev),
EventMsg::McpToolCallBegin(ev) => self.on_mcp_tool_call_begin(ev),
EventMsg::McpToolCallEnd(ev) => self.on_mcp_tool_call_end(ev),
EventMsg::WebSearchBegin(ev) => self.on_web_search_begin(ev),
EventMsg::WebSearchEnd(ev) => self.on_web_search_end(ev),
EventMsg::McpListToolsResponse(ev) => self.on_list_mcp_tools(ev),
EventMsg::ListSkillsResponse(ev) => self.on_list_skills(ev),
EventMsg::SkillsUpdateAvailable => {
self.refresh_skills_for_current_cwd(/*force_reload*/ true);
}
EventMsg::ShutdownComplete => self.on_shutdown_complete(),
EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => self.on_turn_diff(unified_diff),
EventMsg::DeprecationNotice(ev) => self.on_deprecation_notice(ev.summary, ev.details),
EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => {
self.on_background_event(message)
}
EventMsg::UndoStarted(ev) => self.on_undo_started(ev),
EventMsg::UndoCompleted(ev) => self.on_undo_completed(ev),
EventMsg::StreamError(StreamErrorEvent {
message,
additional_details,
..
}) => {
if !is_resume_initial_replay {
self.on_stream_error(message, additional_details);
}
}
EventMsg::UserMessage(ev) => {
if from_replay || self.should_render_realtime_user_message_event(&ev) {
self.on_user_message_event(ev);
}
}
EventMsg::EnteredReviewMode(hint) => {
self.enter_review_mode_with_hint(hint, from_replay)
}
EventMsg::ExitedReviewMode(review) => self.on_exited_review_mode(review),
EventMsg::ThreadRolledBack(rollback) => {
if from_replay {
self.app_event_tx.send(AppEvent::ApplyThreadRollback {
num_turns: rollback.num_turns,
});
}
}
EventMsg::HookStarted(event) => self.on_hook_started(event),
EventMsg::HookCompleted(event) => self.on_hook_completed(event),
EventMsg::ItemCompleted(event) => {
let item = event.item;
if !from_replay && let codex_protocol::items::TurnItem::UserMessage(item) = &item {
self.on_committed_user_message(item, from_replay);
}
if let codex_protocol::items::TurnItem::Plan(plan_item) = &item {
self.on_plan_item_completed(plan_item.text.clone());
}
if let codex_protocol::items::TurnItem::AgentMessage(item) = item {
self.on_agent_message_item_completed(item);
}
}
}
if !from_replay && self.agent_turn_running {
self.refresh_runtime_metrics();
}
}
fn enter_review_mode_with_hint(&mut self, hint: String, from_replay: bool) {
if self.pre_review_token_info.is_none() {
self.pre_review_token_info = Some(self.token_info.clone());
@@ -7273,11 +6812,6 @@ impl ChatWidget {
self.request_redraw();
}
#[cfg(test)]
fn on_exited_review_mode(&mut self, _review: ExitedReviewModeEvent) {
self.exit_review_mode_after_item();
}
fn on_committed_user_message(&mut self, item: &UserMessageItem, from_replay: bool) {
let rendered_event = Self::rendered_user_message_event_from_inputs(&item.content);
let event = UserMessageEvent {
@@ -11115,17 +10649,6 @@ impl ChatWidget {
}
}
#[cfg(test)]
fn on_list_mcp_tools(&mut self, ev: McpListToolsResponseEvent) {
self.add_to_history(history_cell::new_mcp_tools_output(
&self.config,
ev.tools,
ev.resources,
ev.resource_templates,
&ev.auth_statuses,
));
}
fn on_list_skills(&mut self, ev: SkillsListResponse) {
self.set_skills_from_response(&ev);
self.refresh_plugin_mentions();
@@ -11744,8 +11267,5 @@ pub(crate) fn show_review_commit_picker_with_entries(
});
}
#[cfg(test)]
pub(crate) mod test_events;
#[cfg(test)]
pub(crate) mod tests;
-12
View File
@@ -65,18 +65,6 @@ impl RealtimeConversationUiState {
}
impl ChatWidget {
#[cfg(test)]
pub(super) fn should_render_realtime_user_message_event(
&self,
event: &UserMessageEvent,
) -> bool {
if !self.realtime_conversation.is_live() {
return false;
}
let key = Self::rendered_user_message_event_from_event(event);
self.last_rendered_user_message_event.as_ref() != Some(&key)
}
fn realtime_footer_hint_items() -> Vec<(String, String)> {
vec![("/realtime".to_string(), "stop live voice".to_string())]
}
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/status_and_layout.rs
expression: combined
---
• Here is the result.
@@ -33,7 +33,6 @@ const TERMINAL_TITLE_ACTION_REQUIRED_PREFIX_HIDDEN: &str = "[ . ] Action Require
pub(super) enum TerminalTitleStatusKind {
Working,
WaitingForBackgroundTerminal,
Undoing,
#[default]
Thinking,
}
@@ -711,7 +710,6 @@ impl ChatWidget {
}
TerminalTitleStatusKind::Working => "Working".to_string(),
TerminalTitleStatusKind::WaitingForBackgroundTerminal => "Waiting".to_string(),
TerminalTitleStatusKind::Undoing => "Undoing".to_string(),
TerminalTitleStatusKind::Thinking => "Thinking".to_string(),
}
}
@@ -748,9 +746,7 @@ impl ChatWidget {
return false;
}
self.mcp_startup_status.is_some()
|| self.bottom_pane.is_task_running()
|| self.terminal_title_status_kind == TerminalTitleStatusKind::Undoing
self.mcp_startup_status.is_some() || self.bottom_pane.is_task_running()
}
pub(super) fn should_animate_terminal_title_spinner(&self) -> bool {
-309
View File
@@ -1,309 +0,0 @@
//! Test-only event fixtures for legacy-style ChatWidget coverage.
#![allow(dead_code)]
use std::collections::HashMap;
use std::path::PathBuf;
use crate::approval_events::ApplyPatchApprovalRequestEvent;
use crate::approval_events::ExecApprovalRequestEvent;
use crate::session_state::SessionNetworkProxyRuntime;
use crate::session_state::ThreadSessionState;
use crate::token_usage::TokenCountEvent;
use crate::tool_activity::ExecCommandBeginEvent;
use crate::tool_activity::ExecCommandEndEvent;
use crate::tool_activity::ExecCommandOutputDeltaEvent;
use crate::tool_activity::HookCompletedEvent;
use crate::tool_activity::HookStartedEvent;
use crate::tool_activity::ImageGenerationBeginEvent;
use crate::tool_activity::ImageGenerationEndEvent;
use crate::tool_activity::McpToolCallBeginEvent;
use crate::tool_activity::McpToolCallEndEvent;
use crate::tool_activity::PatchApplyBeginEvent;
use crate::tool_activity::PatchApplyEndEvent;
use crate::tool_activity::TerminalInteractionEvent;
use crate::tool_activity::ViewImageToolCallEvent;
use crate::tool_activity::WebSearchBeginEvent;
use crate::tool_activity::WebSearchEndEvent;
use crate::turn_state::TurnAbortReason;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::CodexErrorInfo;
use codex_app_server_protocol::McpAuthStatus;
use codex_app_server_protocol::ModelVerification;
use codex_protocol::ThreadId;
use codex_protocol::approvals::ElicitationRequestEvent;
use codex_protocol::approvals::GuardianAssessmentEvent;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::items::TurnItem;
use codex_protocol::mcp::Resource as McpResource;
use codex_protocol::mcp::ResourceTemplate as McpResourceTemplate;
use codex_protocol::mcp::Tool as McpTool;
use codex_protocol::memory_citation::MemoryCitation;
use codex_protocol::models::MessagePhase;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::plan_tool::UpdatePlanArgs;
use codex_protocol::request_permissions::RequestPermissionsEvent;
use codex_protocol::request_user_input::RequestUserInputEvent;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use super::user_messages::UserMessageEvent;
#[derive(Debug, Clone)]
pub(crate) struct Event {
pub(crate) id: String,
pub(crate) msg: EventMsg,
}
#[derive(Debug, Clone)]
pub(crate) struct SessionConfiguredEvent {
pub(crate) session_id: ThreadId,
pub(crate) forked_from_id: Option<ThreadId>,
pub(crate) thread_name: Option<String>,
pub(crate) model: String,
#[allow(dead_code)]
pub(crate) model_provider_id: String,
pub(crate) service_tier: Option<ServiceTier>,
pub(crate) approval_policy: AskForApproval,
pub(crate) approvals_reviewer: ApprovalsReviewer,
pub(crate) permission_profile: PermissionProfile,
pub(crate) cwd: AbsolutePathBuf,
pub(crate) reasoning_effort: Option<ReasoningEffortConfig>,
pub(crate) history_log_id: u64,
pub(crate) history_entry_count: usize,
pub(crate) initial_messages: Option<Vec<EventMsg>>,
pub(crate) network_proxy: Option<SessionNetworkProxyRuntime>,
pub(crate) rollout_path: Option<PathBuf>,
}
impl SessionConfiguredEvent {
pub(crate) fn into_session(self) -> (ThreadSessionState, Option<Vec<EventMsg>>) {
(
ThreadSessionState {
thread_id: self.session_id,
forked_from_id: self.forked_from_id,
fork_parent_title: None,
thread_name: self.thread_name,
model: self.model,
model_provider_id: self.model_provider_id,
service_tier: self.service_tier,
approval_policy: self.approval_policy,
approvals_reviewer: self.approvals_reviewer,
permission_profile: self.permission_profile,
cwd: self.cwd,
instruction_source_paths: Vec::new(),
reasoning_effort: self.reasoning_effort,
history_log_id: self.history_log_id,
history_entry_count: u64::try_from(self.history_entry_count).unwrap_or(u64::MAX),
network_proxy: self.network_proxy,
rollout_path: self.rollout_path,
},
self.initial_messages,
)
}
}
#[derive(Debug, Clone)]
#[allow(clippy::large_enum_variant)]
pub(crate) enum EventMsg {
Error(ErrorEvent),
Warning(WarningEvent),
GuardianWarning(WarningEvent),
ModelVerification(ModelVerificationEvent),
ThreadRolledBack(ThreadRolledBackEvent),
TurnStarted(TurnStartedEvent),
TurnComplete(TurnCompleteEvent),
TokenCount(TokenCountEvent),
AgentMessage(AgentMessageEvent),
UserMessage(UserMessageEvent),
AgentMessageDelta(AgentMessageDeltaEvent),
AgentReasoning(AgentReasoningEvent),
AgentReasoningDelta(AgentReasoningDeltaEvent),
AgentReasoningRawContent(AgentReasoningRawContentEvent),
AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent),
SessionConfigured(SessionConfiguredEvent),
ThreadNameUpdated(ThreadNameUpdatedEvent),
McpToolCallBegin(McpToolCallBeginEvent),
McpToolCallEnd(McpToolCallEndEvent),
WebSearchBegin(WebSearchBeginEvent),
WebSearchEnd(WebSearchEndEvent),
ImageGenerationBegin(ImageGenerationBeginEvent),
ImageGenerationEnd(ImageGenerationEndEvent),
ExecCommandBegin(ExecCommandBeginEvent),
ExecCommandOutputDelta(ExecCommandOutputDeltaEvent),
TerminalInteraction(TerminalInteractionEvent),
ExecCommandEnd(ExecCommandEndEvent),
ViewImageToolCall(ViewImageToolCallEvent),
ExecApprovalRequest(ExecApprovalRequestEvent),
RequestPermissions(RequestPermissionsEvent),
RequestUserInput(RequestUserInputEvent),
ElicitationRequest(ElicitationRequestEvent),
ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent),
GuardianAssessment(GuardianAssessmentEvent),
DeprecationNotice(DeprecationNoticeEvent),
BackgroundEvent(BackgroundEventEvent),
UndoStarted(UndoStartedEvent),
UndoCompleted(UndoCompletedEvent),
StreamError(StreamErrorEvent),
PatchApplyBegin(PatchApplyBeginEvent),
PatchApplyEnd(PatchApplyEndEvent),
TurnDiff(TurnDiffEvent),
McpListToolsResponse(McpListToolsResponseEvent),
ListSkillsResponse(codex_app_server_protocol::SkillsListResponse),
SkillsUpdateAvailable,
PlanUpdate(UpdatePlanArgs),
TurnAborted(TurnAbortedEvent),
ShutdownComplete,
EnteredReviewMode(String),
ExitedReviewMode(ExitedReviewModeEvent),
ItemCompleted(ItemCompletedEvent),
HookStarted(HookStartedEvent),
HookCompleted(HookCompletedEvent),
PlanDelta(PlanDeltaEvent),
}
#[derive(Debug, Clone)]
pub(crate) struct ErrorEvent {
pub(crate) message: String,
pub(crate) codex_error_info: Option<CodexErrorInfo>,
}
#[derive(Debug, Clone)]
pub(crate) struct WarningEvent {
pub(crate) message: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ModelVerificationEvent {
pub(crate) verifications: Vec<ModelVerification>,
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct TurnCompleteEvent {
pub(crate) turn_id: String,
pub(crate) last_agent_message: Option<String>,
pub(crate) completed_at: Option<i64>,
pub(crate) duration_ms: Option<i64>,
pub(crate) time_to_first_token_ms: Option<i64>,
}
#[derive(Debug, Clone)]
pub(crate) struct TurnStartedEvent {
pub(crate) turn_id: String,
pub(crate) started_at: Option<i64>,
pub(crate) model_context_window: Option<i64>,
pub(crate) collaboration_mode_kind: codex_protocol::config_types::ModeKind,
}
#[derive(Debug, Clone)]
pub(crate) struct AgentMessageEvent {
pub(crate) message: String,
pub(crate) phase: Option<MessagePhase>,
pub(crate) memory_citation: Option<MemoryCitation>,
}
#[derive(Debug, Clone)]
pub(crate) struct AgentMessageDeltaEvent {
pub(crate) delta: String,
}
#[derive(Debug, Clone)]
pub(crate) struct AgentReasoningEvent {
pub(crate) text: String,
}
#[derive(Debug, Clone)]
pub(crate) struct AgentReasoningRawContentEvent {
pub(crate) text: String,
}
#[derive(Debug, Clone)]
pub(crate) struct AgentReasoningRawContentDeltaEvent {
pub(crate) delta: String,
}
#[derive(Debug, Clone)]
pub(crate) struct AgentReasoningDeltaEvent {
pub(crate) delta: String,
}
#[derive(Debug, Clone)]
pub(crate) struct BackgroundEventEvent {
pub(crate) message: String,
}
#[derive(Debug, Clone)]
pub(crate) struct DeprecationNoticeEvent {
pub(crate) summary: String,
pub(crate) details: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct UndoStartedEvent {
pub(crate) message: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct UndoCompletedEvent {
pub(crate) success: bool,
pub(crate) message: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct ThreadRolledBackEvent {
pub(crate) num_turns: u32,
}
#[derive(Debug, Clone)]
pub(crate) struct StreamErrorEvent {
pub(crate) message: String,
pub(crate) codex_error_info: Option<CodexErrorInfo>,
pub(crate) additional_details: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct TurnDiffEvent {
pub(crate) unified_diff: String,
}
#[derive(Debug, Clone)]
pub(crate) struct McpListToolsResponseEvent {
pub(crate) tools: HashMap<String, McpTool>,
pub(crate) resources: HashMap<String, Vec<McpResource>>,
pub(crate) resource_templates: HashMap<String, Vec<McpResourceTemplate>>,
pub(crate) auth_statuses: HashMap<String, McpAuthStatus>,
}
#[derive(Debug, Clone)]
pub(crate) struct ThreadNameUpdatedEvent {
pub(crate) thread_id: ThreadId,
pub(crate) thread_name: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct TurnAbortedEvent {
pub(crate) turn_id: Option<String>,
pub(crate) reason: TurnAbortReason,
pub(crate) completed_at: Option<i64>,
pub(crate) duration_ms: Option<i64>,
}
#[derive(Debug, Clone)]
pub(crate) struct ItemCompletedEvent {
pub(crate) thread_id: ThreadId,
pub(crate) turn_id: String,
pub(crate) item: TurnItem,
}
#[derive(Debug, Clone)]
pub(crate) struct PlanDeltaEvent {
pub(crate) thread_id: String,
pub(crate) turn_id: String,
pub(crate) item_id: String,
pub(crate) delta: String,
}
#[derive(Debug, Clone)]
pub(crate) struct ExitedReviewModeEvent;
-29
View File
@@ -17,25 +17,6 @@ pub(super) use crate::bottom_pane::LocalImageAttachment;
pub(super) use crate::bottom_pane::MentionBinding;
pub(super) use crate::bottom_pane::QueuedInputAction;
pub(super) use crate::chatwidget::realtime::RealtimeConversationPhase;
pub(super) use crate::chatwidget::test_events::AgentMessageDeltaEvent;
pub(super) use crate::chatwidget::test_events::AgentMessageEvent;
pub(super) use crate::chatwidget::test_events::AgentReasoningDeltaEvent;
pub(super) use crate::chatwidget::test_events::AgentReasoningEvent;
pub(super) use crate::chatwidget::test_events::BackgroundEventEvent;
pub(super) use crate::chatwidget::test_events::ErrorEvent;
pub(super) use crate::chatwidget::test_events::Event;
pub(super) use crate::chatwidget::test_events::EventMsg;
pub(super) use crate::chatwidget::test_events::ExitedReviewModeEvent;
pub(super) use crate::chatwidget::test_events::ItemCompletedEvent;
pub(super) use crate::chatwidget::test_events::ModelVerificationEvent;
pub(super) use crate::chatwidget::test_events::SessionConfiguredEvent;
pub(super) use crate::chatwidget::test_events::StreamErrorEvent;
pub(super) use crate::chatwidget::test_events::ThreadRolledBackEvent;
pub(super) use crate::chatwidget::test_events::TurnCompleteEvent;
pub(super) use crate::chatwidget::test_events::TurnStartedEvent;
pub(super) use crate::chatwidget::test_events::UndoCompletedEvent;
pub(super) use crate::chatwidget::test_events::UndoStartedEvent;
pub(super) use crate::chatwidget::test_events::WarningEvent;
pub(super) use crate::diff_model::FileChange;
pub(super) use crate::history_cell::UserHistoryCell;
pub(super) use crate::legacy_core::config::Config;
@@ -47,7 +28,6 @@ pub(super) use crate::test_backend::VT100Backend;
pub(super) use crate::test_support::PathBufExt;
pub(super) use crate::test_support::test_path_buf;
pub(super) use crate::test_support::test_path_display;
pub(super) use crate::token_usage::TokenCountEvent;
pub(super) use crate::token_usage::TokenUsage;
pub(super) use crate::token_usage::TokenUsageInfo;
pub(super) use crate::tool_activity::ExecCommandBeginEvent;
@@ -75,7 +55,6 @@ pub(super) use codex_app_server_protocol::CommandAction as AppServerCommandActio
pub(super) use codex_app_server_protocol::CommandExecutionRequestApprovalParams as AppServerCommandExecutionRequestApprovalParams;
pub(super) use codex_app_server_protocol::CommandExecutionSource as ExecCommandSource;
pub(super) use codex_app_server_protocol::CommandExecutionSource as AppServerCommandExecutionSource;
pub(super) use codex_app_server_protocol::CommandExecutionStatus as CoreExecCommandStatus;
pub(super) use codex_app_server_protocol::CommandExecutionStatus as AppServerCommandExecutionStatus;
pub(super) use codex_app_server_protocol::ConfigWarningNotification;
pub(super) use codex_app_server_protocol::CreditsSnapshot;
@@ -107,11 +86,9 @@ pub(super) use codex_app_server_protocol::MarketplaceInterface;
pub(super) use codex_app_server_protocol::McpServerStartupState;
pub(super) use codex_app_server_protocol::McpServerStatusDetail;
pub(super) use codex_app_server_protocol::McpServerStatusUpdatedNotification;
pub(super) use codex_app_server_protocol::ModelVerification as CoreModelVerification;
pub(super) use codex_app_server_protocol::ModelVerification as AppServerModelVerification;
pub(super) use codex_app_server_protocol::ModelVerificationNotification;
pub(super) use codex_app_server_protocol::NonSteerableTurnKind;
pub(super) use codex_app_server_protocol::PatchApplyStatus as CorePatchApplyStatus;
pub(super) use codex_app_server_protocol::PatchApplyStatus as AppServerPatchApplyStatus;
pub(super) use codex_app_server_protocol::PatchChangeKind;
pub(super) use codex_app_server_protocol::PermissionsRequestApprovalParams as AppServerPermissionsRequestApprovalParams;
@@ -175,11 +152,6 @@ pub(super) use codex_protocol::config_types::ModeKind;
pub(super) use codex_protocol::config_types::Personality;
pub(super) use codex_protocol::config_types::ServiceTier;
pub(super) use codex_protocol::config_types::Settings;
pub(super) use codex_protocol::items::AgentMessageContent;
pub(super) use codex_protocol::items::AgentMessageItem;
pub(super) use codex_protocol::items::PlanItem;
pub(super) use codex_protocol::items::TurnItem;
pub(super) use codex_protocol::items::UserMessageItem;
pub(super) use codex_protocol::models::FileSystemPermissions;
pub(super) use codex_protocol::models::MessagePhase;
pub(super) use codex_protocol::models::NetworkPermissions;
@@ -257,7 +229,6 @@ macro_rules! assert_chatwidget_snapshot {
mod app_server;
mod approval_requests;
mod background_events;
mod composer_submission;
mod exec_flow;
mod goal_menu;
@@ -25,10 +25,7 @@ async fn exec_approval_emits_proposed_command_and_decision_history() {
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!(
@@ -192,9 +189,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(),
@@ -209,8 +207,8 @@ async fn exec_approval_uses_approval_id_when_present() {
additional_permissions: None,
available_decisions: None,
parsed_cmd: vec![],
}),
});
},
);
chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
@@ -253,10 +251,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() {
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(),
@@ -306,10 +301,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() {
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());
}
@@ -14,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 = crate::chatwidget::test_events::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(),
@@ -25,17 +26,14 @@ async fn submission_preserves_text_elements_and_local_images() {
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: PermissionProfile::read_only(),
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]";
@@ -119,9 +117,10 @@ async fn submission_includes_configured_permission_profile() {
},
}
.into();
let configured = crate::chatwidget::test_events::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(),
@@ -130,17 +129,14 @@ async fn submission_includes_configured_permission_profile() {
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: expected_permission_profile.clone(),
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(
@@ -170,9 +166,10 @@ async fn submission_keeps_profile_when_legacy_projection_is_external() {
file_system: PermissionProfileFileSystemPermissions::Unrestricted,
}
.into();
let configured = crate::chatwidget::test_events::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(),
@@ -181,17 +178,14 @@ async fn submission_keeps_profile_when_legacy_projection_is_external() {
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: expected_permission_profile.clone(),
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
@@ -213,9 +207,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 = crate::chatwidget::test_events::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(),
@@ -224,17 +219,14 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: PermissionProfile::read_only(),
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();
@@ -308,9 +300,10 @@ async fn enter_with_only_remote_images_submits_user_turn() {
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = crate::chatwidget::test_events::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(),
@@ -319,17 +312,14 @@ async fn enter_with_only_remote_images_submits_user_turn() {
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: PermissionProfile::read_only(),
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();
@@ -373,9 +363,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 = crate::chatwidget::test_events::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(),
@@ -384,17 +375,14 @@ async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() {
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: PermissionProfile::read_only(),
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();
@@ -413,9 +401,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 = crate::chatwidget::test_events::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(),
@@ -424,17 +413,14 @@ async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() {
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: PermissionProfile::read_only(),
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();
@@ -453,9 +439,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 = crate::chatwidget::test_events::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(),
@@ -464,17 +451,14 @@ async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() {
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: PermissionProfile::read_only(),
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();
@@ -496,9 +480,10 @@ async fn submission_prefers_selected_duplicate_skill_path() {
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = crate::chatwidget::test_events::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 +492,14 @@ async fn submission_prefers_selected_duplicate_skill_path() {
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: PermissionProfile::read_only(),
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();
@@ -738,15 +720,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(crate::chatwidget::test_events::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());
@@ -1184,16 +1158,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(crate::chatwidget::test_events::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 +1192,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(crate::chatwidget::test_events::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(),
+117 -276
View File
@@ -22,10 +22,7 @@ async fn exec_approval_emits_proposed_command_and_decision_history() {
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 +86,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(),
@@ -106,8 +104,8 @@ async fn exec_approval_uses_approval_id_when_present() {
additional_permissions: None,
available_decisions: None,
parsed_cmd: vec![],
}),
});
},
);
chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
@@ -151,10 +149,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() {
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(),
@@ -206,10 +201,7 @@ async fn exec_approval_decision_truncates_multiline_and_long_commands() {
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(),
@@ -361,9 +353,9 @@ async fn exec_end_without_begin_uses_event_command() {
];
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 {
handle_exec_end(
&mut chat,
ExecCommandEndEvent {
call_id: "call-orphan".to_string(),
process_id: None,
turn_id: "turn-1".to_string(),
@@ -378,9 +370,9 @@ async fn exec_end_without_begin_uses_event_command() {
exit_code: 0,
duration: std::time::Duration::from_millis(5),
formatted_output: "done".to_string(),
status: CoreExecCommandStatus::Completed,
}),
});
status: AppServerCommandExecutionStatus::Completed,
},
);
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected finalized exec cell to flush");
@@ -621,14 +613,10 @@ 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(),
}),
});
handle_terminal_interaction(
&mut chat,
terminal_interaction_event("call-1", "proc-1", "ls\n"),
);
let cells = drain_insert_history(&mut rx);
assert!(
@@ -640,30 +628,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", None);
let cells = drain_insert_history(&mut rx);
let combined = cells
@@ -676,15 +647,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,
@@ -694,22 +657,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", None);
let cells = drain_insert_history(&mut rx);
let combined = cells
@@ -722,15 +671,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,
@@ -746,16 +687,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
@@ -818,16 +750,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", None);
let cells = drain_insert_history(&mut rx);
let combined = cells
@@ -899,16 +822,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", None);
let post_cells = drain_insert_history(&mut rx);
let mut combined = pre_cells
@@ -931,13 +845,13 @@ 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 {
handle_view_image_tool_call(
&mut chat,
ViewImageToolCallEvent {
call_id: "call-image".into(),
path: image_path,
}),
});
},
);
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected a single history cell");
@@ -949,16 +863,16 @@ 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 {
handle_image_generation_end(
&mut chat,
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()),
}),
});
},
);
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected a single history cell");
@@ -1052,9 +966,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 = crate::chatwidget::test_events::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(),
@@ -1063,28 +978,17 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() {
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: PermissionProfile::read_only(),
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());
@@ -1105,15 +1009,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",
@@ -1130,16 +1026,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", None);
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
@@ -1176,7 +1069,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<()> {
@@ -1208,10 +1101,7 @@ async fn approval_modal_exec_snapshot() -> anyhow::Result<()> {
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;
@@ -1269,10 +1159,7 @@ async fn approval_modal_exec_without_reason_snapshot() -> anyhow::Result<()> {
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);
@@ -1317,10 +1204,7 @@ async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot()
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);
@@ -1364,10 +1248,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);
@@ -1392,15 +1273,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(crate::chatwidget::test_events::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);
@@ -1427,28 +1300,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(crate::chatwidget::test_events::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);
@@ -1469,16 +1326,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", None);
assert_eq!(chat.unified_exec_processes.len(), 2);
@@ -1520,10 +1368,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"
@@ -1543,10 +1388,7 @@ async fn apply_patch_events_emit_history_cells() {
auto_approved: true,
changes: changes2,
};
chat.handle_codex_event(Event {
id: "s1".into(),
msg: EventMsg::PatchApplyBegin(begin),
});
handle_patch_apply_begin(&mut chat, begin);
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());
@@ -1570,12 +1412,9 @@ async fn apply_patch_events_emit_history_cells() {
stderr: String::new(),
success: true,
changes: end_changes,
status: CorePatchApplyStatus::Completed,
status: AppServerPatchApplyStatus::Completed,
};
chat.handle_codex_event(Event {
id: "s1".into(),
msg: EventMsg::PatchApplyEnd(end),
});
handle_patch_apply_end(&mut chat, end);
let cells = drain_insert_history(&mut rx);
assert!(
cells.is_empty(),
@@ -1594,16 +1433,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();
@@ -1613,15 +1453,15 @@ 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 {
handle_patch_apply_begin(
&mut chat,
PatchApplyBeginEvent {
call_id: "c1".into(),
turn_id: "turn-c1".into(),
auto_approved: false,
changes: apply_changes,
}),
});
},
);
let cells = drain_insert_history(&mut rx);
assert!(!cells.is_empty(), "expected apply block cell to be sent");
@@ -1643,16 +1483,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(),
@@ -1666,15 +1507,15 @@ async fn apply_patch_manual_flow_snapshot() {
content: "hello\n".to_string(),
},
);
chat.handle_codex_event(Event {
id: "s1".into(),
msg: EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
handle_patch_apply_begin(
&mut chat,
PatchApplyBeginEvent {
call_id: "c1".into(),
turn_id: "turn-c1".into(),
auto_approved: false,
changes: apply_changes,
}),
});
},
);
let approved_lines = drain_insert_history(&mut rx)
.pop()
.expect("approved patch cell");
@@ -1703,10 +1544,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));
@@ -1741,16 +1579,17 @@ 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));
@@ -1785,32 +1624,32 @@ 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 {
handle_patch_apply_begin(
&mut chat,
PatchApplyBeginEvent {
call_id: "call-1".into(),
turn_id: "turn-call-1".into(),
auto_approved: false,
changes: 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 {
handle_patch_apply_end(
&mut chat,
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,
}),
});
status: AppServerPatchApplyStatus::Completed,
},
);
}
#[tokio::test]
@@ -1828,16 +1667,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);
@@ -1882,16 +1722,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(),
+136 -187
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());
@@ -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");
+687 -93
View File
@@ -497,6 +497,405 @@ 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(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, event: PatchApplyBeginEvent) {
chat.handle_server_notification(
ServerNotification::ItemStarted(ItemStartedNotification {
thread_id: thread_id(chat),
turn_id: event.turn_id.clone(),
item: AppServerThreadItem::FileChange {
id: event.call_id,
changes: file_update_changes_from_tui(event.changes),
status: AppServerPatchApplyStatus::InProgress,
},
}),
/*replay_kind*/ None,
);
}
pub(super) fn handle_patch_apply_end(chat: &mut ChatWidget, event: PatchApplyEndEvent) {
chat.handle_server_notification(
ServerNotification::ItemCompleted(ItemCompletedNotification {
thread_id: thread_id(chat),
turn_id: event.turn_id.clone(),
item: AppServerThreadItem::FileChange {
id: event.call_id,
changes: file_update_changes_from_tui(event.changes),
status: event.status,
},
}),
/*replay_kind*/ None,
);
}
pub(super) fn handle_view_image_tool_call(chat: &mut ChatWidget, event: ViewImageToolCallEvent) {
chat.handle_server_notification(
ServerNotification::ItemCompleted(ItemCompletedNotification {
thread_id: thread_id(chat),
turn_id: "turn-1".to_string(),
item: AppServerThreadItem::ImageView {
id: event.call_id,
path: event.path,
},
}),
/*replay_kind*/ None,
);
}
pub(super) fn handle_image_generation_end(chat: &mut ChatWidget, event: ImageGenerationEndEvent) {
chat.handle_server_notification(
ServerNotification::ItemCompleted(ItemCompletedNotification {
thread_id: thread_id(chat),
turn_id: "turn-1".to_string(),
item: AppServerThreadItem::ImageGeneration {
id: event.call_id,
status: event.status,
revised_prompt: event.revised_prompt,
result: event.result,
saved_path: event.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,
@@ -521,10 +920,7 @@ pub(super) fn begin_exec_with_source(
source,
interaction_input,
};
chat.handle_codex_event(Event {
id: call_id.to_string(),
msg: EventMsg::ExecCommandBegin(event.clone()),
});
handle_exec_begin(chat, event.clone());
event
}
@@ -546,27 +942,89 @@ pub(super) fn begin_unified_exec_startup(
source: ExecCommandSource::UnifiedExecStartup,
interaction_input: None,
};
chat.handle_codex_event(Event {
id: call_id.to_string(),
msg: EventMsg::ExecCommandBegin(event.clone()),
});
handle_exec_begin(chat, event.clone());
event
}
pub(super) fn handle_exec_begin(chat: &mut ChatWidget, event: ExecCommandBeginEvent) {
let command_actions = event
.parsed_cmd
.iter()
.cloned()
.map(|parsed| AppServerCommandAction::from_core_with_cwd(parsed, &event.cwd))
.collect();
chat.handle_server_notification(
ServerNotification::ItemStarted(ItemStartedNotification {
thread_id: thread_id(chat),
turn_id: event.turn_id.clone(),
item: AppServerThreadItem::CommandExecution {
id: event.call_id,
command: codex_shell_command::parse_command::shlex_join(&event.command),
cwd: event.cwd,
process_id: event.process_id,
source: event.source,
status: AppServerCommandExecutionStatus::InProgress,
command_actions,
aggregated_output: None,
exit_code: None,
duration_ms: None,
},
}),
/*replay_kind*/ None,
);
}
pub(super) fn terminal_interaction(
chat: &mut ChatWidget,
call_id: &str,
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 handle_terminal_interaction(chat: &mut ChatWidget, event: TerminalInteractionEvent) {
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: event.call_id,
process_id: event.process_id,
stdin: event.stdin,
},
),
/*replay_kind*/ None,
);
}
pub(super) fn terminal_interaction_event(
call_id: &str,
process_id: &str,
stdin: &str,
) -> TerminalInteractionEvent {
TerminalInteractionEvent {
call_id: call_id.to_string(),
process_id: process_id.to_string(),
stdin: stdin.to_string(),
}
}
pub(super) fn complete_assistant_message(
@@ -575,21 +1033,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 {
@@ -619,17 +1075,88 @@ 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,
}),
content: content.into_iter().map(AppServerUserInput::from).collect(),
},
}),
});
/*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(
@@ -662,9 +1189,9 @@ pub(super) fn end_exec(
interaction_input,
process_id,
} = begin_event;
chat.handle_codex_event(Event {
id: call_id.clone(),
msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent {
handle_exec_end(
chat,
ExecCommandEndEvent {
call_id,
process_id,
turn_id,
@@ -680,12 +1207,42 @@ pub(super) fn end_exec(
duration: std::time::Duration::from_millis(5),
formatted_output: aggregated,
status: if exit_code == 0 {
CoreExecCommandStatus::Completed
AppServerCommandExecutionStatus::Completed
} else {
CoreExecCommandStatus::Failed
AppServerCommandExecutionStatus::Failed
},
},
);
}
pub(super) fn handle_exec_end(chat: &mut ChatWidget, event: ExecCommandEndEvent) {
let duration_ms = i64::try_from(event.duration.as_millis()).unwrap_or(i64::MAX);
let command_actions = event
.parsed_cmd
.iter()
.cloned()
.map(|parsed| AppServerCommandAction::from_core_with_cwd(parsed, &event.cwd))
.collect();
chat.handle_server_notification(
ServerNotification::ItemCompleted(ItemCompletedNotification {
thread_id: thread_id(chat),
turn_id: event.turn_id.clone(),
item: AppServerThreadItem::CommandExecution {
id: event.call_id,
command: codex_shell_command::parse_command::shlex_join(&event.command),
cwd: event.cwd,
process_id: event.process_id,
source: event.source,
status: event.status,
command_actions,
aggregated_output: (!event.aggregated_output.is_empty())
.then_some(event.aggregated_output),
exit_code: Some(event.exit_code),
duration_ms: Some(duration_ms),
},
}),
});
/*replay_kind*/ None,
);
}
pub(super) fn active_blob(chat: &ChatWidget) -> String {
@@ -1015,6 +1572,67 @@ 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_app_server_protocol::HookEventName,
run_id: &str,
@@ -1023,28 +1641,16 @@ pub(super) async fn assert_hook_events_snapshot(
) {
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(crate::tool_activity::HookStartedEvent {
turn_id: None,
run: 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: codex_app_server_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"
@@ -1058,37 +1664,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(crate::tool_activity::HookCompletedEvent {
turn_id: None,
run: 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: codex_app_server_protocol::HookRunStatus::Completed,
status_message: Some(status_message.to_string()),
started_at: 1,
completed_at: Some(11),
duration_ms: Some(10),
entries: 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(),
},
],
},
}),
});
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
@@ -16,41 +16,39 @@ async fn resumed_initial_messages_render_history() {
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = crate::chatwidget::test_events::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(),
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();
@@ -74,47 +72,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;
@@ -129,34 +86,41 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() {
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = crate::chatwidget::test_events::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(),
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,34 +154,41 @@ async fn replayed_user_message_preserves_remote_image_urls() {
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
let configured = crate::chatwidget::test_events::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(),
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() {
@@ -282,9 +253,10 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() {
.to_legacy_sandbox_policy(expected_cwd.as_path())
.expect("permission profile should project to legacy sandbox policy");
let expected_sandbox = SandboxPolicy::from(expected_core_sandbox);
let configured = crate::chatwidget::test_events::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(),
@@ -293,18 +265,15 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() {
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: expected_permission_profile,
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!(
AskForApproval::from(chat.config_ref().permissions.approval_policy.value()),
@@ -340,9 +309,10 @@ async fn session_configured_external_sandbox_keeps_external_runtime_policy() {
let expected_sandbox = SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Restricted,
};
let configured = crate::chatwidget::test_events::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(),
@@ -351,18 +321,15 @@ async fn session_configured_external_sandbox_keeps_external_runtime_policy() {
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: expected_permission_profile,
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);
@@ -380,34 +347,35 @@ 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 = crate::chatwidget::test_events::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(),
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() {
@@ -429,38 +397,39 @@ 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 = crate::chatwidget::test_events::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(),
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() {
@@ -620,72 +589,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("current changes".to_string()),
});
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;
@@ -749,26 +667,24 @@ 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(),
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(),
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);
@@ -796,26 +712,24 @@ 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(),
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(),
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);
@@ -893,34 +807,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());
@@ -962,11 +853,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!(
@@ -982,33 +874,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
@@ -1019,93 +896,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
@@ -46,15 +46,7 @@ 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());
@@ -571,26 +571,24 @@ 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(),
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(),
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();
@@ -620,26 +618,24 @@ async fn permissions_selection_marks_auto_review_current_with_custom_workspace_w
let cwd = test_project_path().abs();
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,
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,
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();
+65 -60
View File
@@ -802,13 +802,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 +839,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", None);
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert!(
@@ -866,16 +883,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", None);
let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80);
assert!(
!duplicate_popup.contains(PLAN_IMPLEMENTATION_TITLE),
@@ -1137,15 +1151,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 +1198,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 = crate::chatwidget::test_events::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(),
@@ -1197,17 +1210,14 @@ async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() {
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: PermissionProfile::read_only(),
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 {
@@ -1431,9 +1441,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 = crate::chatwidget::test_events::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(),
@@ -1442,17 +1453,14 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() {
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: PermissionProfile::read_only(),
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());
@@ -1652,10 +1660,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());
@@ -2204,13 +2204,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 {
+58 -318
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(crate::chatwidget::test_events::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,10 +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("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"));
@@ -127,10 +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("current changes".to_string()),
});
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"));
@@ -139,13 +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("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"));
@@ -211,19 +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("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());
@@ -253,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!(
@@ -283,20 +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),
});
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", None);
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
@@ -309,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", None);
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
@@ -336,20 +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("current changes".to_string()),
});
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);
@@ -365,33 +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("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),
});
handle_exited_review_mode(&mut chat);
let _ = drain_insert_history(&mut rx);
assert_eq!(chat.bottom_pane.context_window_percent(), Some(30));
@@ -960,61 +890,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(crate::chatwidget::test_events::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;
@@ -1230,15 +1105,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(crate::chatwidget::test_events::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!(
@@ -1258,26 +1125,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(crate::chatwidget::test_events::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!(
@@ -1356,24 +1207,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(crate::chatwidget::test_events::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());
@@ -1387,24 +1222,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(crate::chatwidget::test_events::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!(
@@ -1424,25 +1243,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(crate::chatwidget::test_events::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
@@ -1524,63 +1327,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(crate::chatwidget::test_events::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("current changes".to_string()),
});
handle_entered_review_mode(&mut chat, "current changes");
let _ = drain_insert_history(&mut rx);
chat.bottom_pane.set_composer_text(
@@ -1613,34 +1366,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("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) {
@@ -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,10 +90,7 @@ 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!(
@@ -112,22 +105,11 @@ 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() {
@@ -153,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));
@@ -176,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");
@@ -195,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"),
@@ -215,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
@@ -263,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"),
@@ -315,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);
@@ -367,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!(
@@ -411,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"));
@@ -445,13 +353,15 @@ async fn queued_bare_rename_drains_next_input_after_name_update() {
"expected rename prompt to submit thread name; events: {events:?}"
);
chat.handle_codex_event(Event {
id: "rename".into(),
msg: EventMsg::ThreadNameUpdated(crate::chatwidget::test_events::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!(
@@ -471,24 +381,13 @@ 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!(
@@ -528,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(crate::chatwidget::test_events::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!(
@@ -542,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!(
@@ -573,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
@@ -1253,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(),
@@ -1275,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", None);
assert_eq!(chat.last_agent_markdown_text(), Some(plan_text.as_str()));
assert_matches!(
@@ -1432,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!(
@@ -1450,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",
@@ -1501,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", None);
let _ = drain_insert_history(&mut rx);
assert_eq!(
@@ -1527,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", None);
assert_matches!(
chat.pending_notification,
@@ -1570,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);
}
@@ -1582,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());
@@ -1607,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,
@@ -1635,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"));
@@ -1685,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);
@@ -1948,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;
@@ -2100,23 +1750,12 @@ 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!(
@@ -2193,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
@@ -23,10 +23,7 @@ async fn terminal_title_shows_action_required_while_exec_approval_is_pending() {
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();
@@ -69,10 +66,7 @@ async fn terminal_title_action_required_respects_spinner_setting() {
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();
@@ -101,10 +95,7 @@ async fn terminal_title_action_required_blinks_when_animations_are_enabled() {
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();
@@ -140,10 +131,7 @@ async fn terminal_title_activity_indicators_do_not_animate_when_animations_are_d
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();
+1 -1
View File
@@ -3,7 +3,7 @@
//! App-server turn items and queued TUI submissions describe user input in
//! slightly different shapes. This module keeps the display-only representation
//! and comparison keys together so chat rendering can avoid duplicate user rows
//! without depending on core protocol event wrappers.
//! while preserving local-only attachment metadata.
use std::path::PathBuf;
@@ -148,11 +148,6 @@ impl StatusIndicatorWidget {
self.show_interrupt_hint = visible;
}
#[cfg(test)]
pub(crate) fn interrupt_hint_visible(&self) -> bool {
self.show_interrupt_hint
}
pub(crate) fn pause_timer(&mut self) {
self.pause_timer_at(Instant::now());
}
-7
View File
@@ -57,13 +57,6 @@ pub(crate) struct TokenUsageInfo {
pub(crate) model_context_window: Option<i64>,
}
#[cfg(test)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct TokenCountEvent {
pub(crate) info: Option<TokenUsageInfo>,
pub(crate) rate_limits: Option<codex_app_server_protocol::RateLimitSnapshot>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FinalOutput {
pub token_usage: TokenUsage,
-2
View File
@@ -7,7 +7,5 @@ use serde::Serialize;
#[serde(rename_all = "snake_case")]
pub(crate) enum TurnAbortReason {
Interrupted,
Replaced,
ReviewEnded,
BudgetLimited,
}