Compare commits

...

2 Commits

Author SHA1 Message Date
Eric Traut
dbc2a2b2bd Merge origin/main into tui test cleanup 2026-04-20 18:18:48 -07:00
Eric Traut
3003f069cb tui: colocate app module tests 2026-04-20 18:04:32 -07:00
8 changed files with 1010 additions and 940 deletions

View File

@@ -8,4 +8,3 @@ For complex logic changes the size should be under 500 lines.
If the change is larger, explain whether it can be split into reviewable stages and identify the smallest coherent stage to land first.
Base the staging suggestion on the actual diff, dependencies, and affected call sites.

View File

@@ -7,6 +7,6 @@ Codex maintains a context (history of messages) that is sent to the model in inf
1. No history rewrite - the context must be built up incrementally.
2. Avoid frequent changes to context that cause cache misses.
3. No unbounded items - everything injected in the model context must have a bounded size and a hard cap.
3. No unbounded items - everything injected in the model context must have a bounded size and a hard cap.
4. No items larger than 10K tokens.
5. Highlight new individual items that can cross >1k tokens as P0. These need an additional manual review.

View File

@@ -193,6 +193,8 @@ mod replay_filter;
mod session_lifecycle;
mod side;
mod startup_prompts;
#[cfg(test)]
mod test_support;
mod thread_events;
mod thread_routing;
mod thread_session_state;

View File

@@ -568,3 +568,549 @@ impl App {
Ok(AppRunControl::Continue)
}
}
#[cfg(test)]
mod tests {
use super::super::test_support::exec_approval_request;
use super::super::test_support::lines_to_single_string;
use super::super::test_support::make_test_app;
use super::super::test_support::make_test_app_with_channels;
use super::super::test_support::request_user_input_request;
use super::super::test_support::test_thread_session;
use super::super::test_support::test_turn;
use super::super::test_support::turn_completed_notification;
use super::super::test_support::turn_started_notification;
use super::*;
use crate::app_event::AppEvent;
use crate::test_support::test_path_buf;
use codex_app_server_protocol::McpServerStartupState;
use codex_app_server_protocol::McpServerStatusUpdatedNotification;
use codex_app_server_protocol::RequestId as AppServerRequestId;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::UserInput as AppServerUserInput;
use codex_protocol::ThreadId;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn side_fork_config_is_ephemeral_and_appends_developer_guardrails() {
let mut app = make_test_app().await;
app.config.developer_instructions = Some("Existing developer policy.".to_string());
let original_approval_policy = app.config.permissions.approval_policy.value();
let original_sandbox_policy = app.config.permissions.sandbox_policy.get().clone();
let fork_config = app.side_fork_config();
assert!(fork_config.ephemeral);
assert_eq!(
fork_config.permissions.approval_policy.value(),
original_approval_policy
);
assert_eq!(
fork_config.permissions.sandbox_policy.get(),
&original_sandbox_policy
);
let developer_instructions = fork_config
.developer_instructions
.as_deref()
.expect("side developer instructions");
assert!(developer_instructions.contains("Existing developer policy."));
assert!(
developer_instructions.contains("You are in a side conversation, not the main thread.")
);
assert!(
developer_instructions
.contains("inherited fork history is provided only as reference context")
);
assert!(developer_instructions.contains(
"Only instructions submitted after the side-conversation boundary are active"
));
assert!(developer_instructions.contains("Do not continue, execute, or complete any task"));
assert!(
developer_instructions
.contains("External tools may be available according to this thread's current")
);
assert!(
developer_instructions
.contains("Any MCP or external tool calls or outputs visible in the inherited")
);
assert!(developer_instructions.contains("non-mutating inspection"));
assert!(developer_instructions.contains("Do not modify files"));
assert!(developer_instructions.contains("Do not request escalated permissions"));
assert!(app.transcript_cells.is_empty());
}
#[test]
fn side_boundary_prompt_marks_inherited_history_reference_only() {
let item = App::side_boundary_prompt_item();
let codex_protocol::models::ResponseItem::Message { role, content, .. } = item else {
panic!("expected hidden side boundary prompt to be a user message");
};
assert_eq!(role, "user");
let [codex_protocol::models::ContentItem::InputText { text }] = content.as_slice() else {
panic!("expected hidden side boundary prompt text");
};
assert!(text.contains("Side conversation boundary."));
assert!(text.contains("Everything before this boundary is inherited history"));
assert!(text.contains("It is not your current task."));
assert!(text.contains("Only messages submitted after this boundary are active"));
assert!(text.contains("Do not continue, execute, or complete"));
assert!(text.contains("separate from the main thread"));
assert!(
text.contains("External tools may be available according to this thread's current")
);
assert!(text.contains("Any tool calls or outputs visible before this boundary happened"));
assert!(text.contains("Do not modify files"));
}
#[test]
fn side_return_shortcuts_match_esc_and_ctrl_c() {
assert!(side_return_shortcut_matches(KeyEvent::new(
KeyCode::Esc,
KeyModifiers::NONE,
)));
assert!(side_return_shortcut_matches(KeyEvent::new_with_kind(
KeyCode::Esc,
KeyModifiers::NONE,
KeyEventKind::Repeat,
)));
assert!(side_return_shortcut_matches(KeyEvent::new(
KeyCode::Char('c'),
KeyModifiers::CONTROL,
)));
assert!(side_return_shortcut_matches(KeyEvent::new(
KeyCode::Char('C'),
KeyModifiers::CONTROL,
)));
assert!(!side_return_shortcut_matches(KeyEvent::new(
KeyCode::Char('d'),
KeyModifiers::CONTROL,
)));
assert!(!side_return_shortcut_matches(KeyEvent::new_with_kind(
KeyCode::Esc,
KeyModifiers::NONE,
KeyEventKind::Release,
)));
}
#[tokio::test]
async fn side_start_block_message_tracks_open_side_conversation() {
let mut app = make_test_app().await;
assert_eq!(
app.side_start_block_message(),
Some("'/side' is unavailable until the main thread is ready.")
);
app.primary_thread_id = Some(ThreadId::new());
assert_eq!(app.side_start_block_message(), None);
let parent_thread_id = ThreadId::new();
let side_thread_id = ThreadId::new();
app.side_threads
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
assert_eq!(
app.side_start_block_message(),
Some(
"A side conversation is already open. Press Esc to return before starting another."
)
);
app.side_threads.remove(&side_thread_id);
assert_eq!(app.side_start_block_message(), None);
}
#[tokio::test]
async fn side_parent_status_tracks_parent_turn_lifecycle() -> color_eyre::eyre::Result<()> {
let mut app = make_test_app().await;
let parent_thread_id = ThreadId::new();
let side_thread_id = ThreadId::new();
app.primary_thread_id = Some(parent_thread_id);
app.active_thread_id = Some(side_thread_id);
app.side_threads
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
app.enqueue_thread_notification(
parent_thread_id,
turn_completed_notification(parent_thread_id, "turn-1", TurnStatus::Completed),
)
.await?;
assert_eq!(
app.side_threads
.get(&side_thread_id)
.and_then(|state| state.parent_status),
Some(SideParentStatus::Finished)
);
app.enqueue_thread_notification(
parent_thread_id,
turn_started_notification(parent_thread_id, "turn-2"),
)
.await?;
assert_eq!(
app.side_threads
.get(&side_thread_id)
.and_then(|state| state.parent_status),
None
);
app.enqueue_thread_notification(
parent_thread_id,
turn_completed_notification(parent_thread_id, "turn-2", TurnStatus::Failed),
)
.await?;
assert_eq!(
app.side_threads
.get(&side_thread_id)
.and_then(|state| state.parent_status),
Some(SideParentStatus::Failed)
);
Ok(())
}
#[tokio::test]
async fn side_parent_status_prioritizes_input_over_approval() -> color_eyre::eyre::Result<()> {
let mut app = make_test_app().await;
let parent_thread_id = ThreadId::new();
let side_thread_id = ThreadId::new();
app.primary_thread_id = Some(parent_thread_id);
app.active_thread_id = Some(side_thread_id);
app.side_threads
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
app.enqueue_thread_request(
parent_thread_id,
exec_approval_request(
parent_thread_id,
"turn-approval",
"call-approval",
/*approval_id*/ None,
),
)
.await?;
assert_eq!(
app.side_threads
.get(&side_thread_id)
.and_then(|state| state.parent_status),
Some(SideParentStatus::NeedsApproval)
);
app.enqueue_thread_request(
parent_thread_id,
request_user_input_request(parent_thread_id, "turn-input", "call-input"),
)
.await?;
assert_eq!(
app.side_threads
.get(&side_thread_id)
.and_then(|state| state.parent_status),
Some(SideParentStatus::NeedsInput)
);
app.enqueue_thread_notification(
parent_thread_id,
ServerNotification::ServerRequestResolved(
codex_app_server_protocol::ServerRequestResolvedNotification {
thread_id: parent_thread_id.to_string(),
request_id: AppServerRequestId::Integer(2),
},
),
)
.await?;
assert_eq!(
app.side_threads
.get(&side_thread_id)
.and_then(|state| state.parent_status),
Some(SideParentStatus::NeedsApproval)
);
app.enqueue_thread_notification(
parent_thread_id,
ServerNotification::ServerRequestResolved(
codex_app_server_protocol::ServerRequestResolvedNotification {
thread_id: parent_thread_id.to_string(),
request_id: AppServerRequestId::Integer(1),
},
),
)
.await?;
assert_eq!(
app.side_threads
.get(&side_thread_id)
.and_then(|state| state.parent_status),
None
);
Ok(())
}
#[test]
fn side_start_error_message_explains_missing_first_prompt() {
let err = color_eyre::eyre::eyre!(
"thread/fork failed during TUI bootstrap: thread/fork failed: no rollout found for thread id 019da1a1-bed9-7a43-88a2-b49d43915021"
);
assert_eq!(
App::side_start_error_message(&err),
"'/side' is unavailable until the current conversation has started. Send a message first, then try /side again."
);
}
#[test]
fn side_start_error_message_uses_generic_start_wording() {
let err = color_eyre::eyre::eyre!("transport disconnected");
assert_eq!(
App::side_start_error_message(&err),
"Failed to start side conversation: transport disconnected"
);
}
#[tokio::test]
async fn side_thread_snapshot_hides_forked_parent_transcript() {
let parent_thread_id = ThreadId::new();
let side_thread_id = ThreadId::new();
let mut store = ThreadEventStore::new(/*capacity*/ 4);
let session = ThreadSessionState {
forked_from_id: Some(parent_thread_id),
..test_thread_session(side_thread_id, test_path_buf("/tmp/side"))
};
let parent_turn = test_turn(
"parent-turn",
TurnStatus::Completed,
vec![ThreadItem::UserMessage {
id: "parent-user".to_string(),
content: vec![AppServerUserInput::Text {
text: "parent prompt should stay hidden".to_string(),
text_elements: Vec::new(),
}],
}],
);
App::install_side_thread_snapshot(&mut store, session, vec![parent_turn]);
let stored_session = store.session.as_ref().expect("side session");
assert_eq!(stored_session.thread_id, side_thread_id);
assert_eq!(stored_session.forked_from_id, None);
assert_eq!(store.turns, Vec::<Turn>::new());
assert_eq!(store.active_turn_id(), None);
}
#[tokio::test]
async fn side_thread_snapshot_does_not_refresh_from_fork_history() {
let mut app = make_test_app().await;
let parent_thread_id = ThreadId::new();
let side_thread_id = ThreadId::new();
app.side_threads
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
let snapshot = ThreadEventSnapshot {
session: Some(ThreadSessionState {
rollout_path: None,
..test_thread_session(side_thread_id, test_path_buf("/tmp/side"))
}),
turns: Vec::new(),
events: Vec::new(),
input_state: None,
};
assert!(!app.should_refresh_snapshot_session(
side_thread_id,
/*is_replay_only*/ false,
&snapshot
));
}
#[tokio::test]
async fn side_thread_snapshot_skips_session_header_preamble() {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
while app_event_rx.try_recv().is_ok() {}
let parent_thread_id = ThreadId::new();
let side_thread_id = ThreadId::new();
app.primary_thread_id = Some(parent_thread_id);
app.side_threads
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
let snapshot = ThreadEventSnapshot {
session: Some(ThreadSessionState {
forked_from_id: Some(parent_thread_id),
..test_thread_session(side_thread_id, test_path_buf("/tmp/side"))
}),
turns: Vec::new(),
events: Vec::new(),
input_state: None,
};
app.replay_thread_snapshot(snapshot, /*resume_restored_queue*/ false);
let mut rendered_cells = Vec::new();
while let Ok(event) = app_event_rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = event {
rendered_cells.push(lines_to_single_string(&cell.display_lines(/*width*/ 120)));
}
}
assert_eq!(app.chat_widget.thread_id(), Some(side_thread_id));
assert_eq!(rendered_cells, Vec::<String>::new());
assert_eq!(
app.chat_widget.active_cell_transcript_lines(/*width*/ 120),
None
);
}
#[tokio::test]
async fn side_thread_ignores_global_mcp_startup_notifications() {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
while app_event_rx.try_recv().is_ok() {}
let app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let parent_thread_id = ThreadId::new();
let side_thread_id = ThreadId::new();
app.primary_thread_id = Some(parent_thread_id);
app.active_thread_id = Some(side_thread_id);
app.side_threads
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
app.sync_side_thread_ui();
app.handle_app_server_event(
&app_server,
codex_app_server_client::AppServerEvent::ServerNotification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
name: "sentry".to_string(),
status: McpServerStartupState::Failed,
error: Some("sentry is not logged in".to_string()),
}),
),
)
.await;
assert!(app_event_rx.try_recv().is_err());
}
#[tokio::test]
async fn side_restore_user_message_puts_inline_question_back_in_composer() {
let mut app = make_test_app().await;
let user_message = crate::chatwidget::UserMessage::from("side question");
app.restore_side_user_message(Some(user_message));
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"side question"
);
}
#[tokio::test]
async fn side_discard_selection_keeps_current_side_thread() {
let mut app = make_test_app().await;
let parent_thread_id = ThreadId::new();
let side_thread_id = ThreadId::new();
app.active_thread_id = Some(side_thread_id);
app.side_threads
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
assert_eq!(
app.side_thread_to_discard_after_switch(side_thread_id),
None
);
assert_eq!(
app.side_thread_to_discard_after_switch(parent_thread_id),
Some(side_thread_id)
);
}
#[tokio::test]
async fn discard_side_thread_removes_agent_navigation_entry() -> color_eyre::eyre::Result<()> {
let mut app = make_test_app().await;
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
let mut side_config = app.chat_widget.config_ref().clone();
side_config.ephemeral = true;
let started = app_server.start_thread(&side_config).await?;
let side_thread_id = started.session.thread_id;
app.side_threads
.insert(side_thread_id, SideThreadState::new(ThreadId::new()));
app.agent_navigation.upsert(
side_thread_id,
Some("Side".to_string()),
Some("side".to_string()),
/*is_closed*/ false,
);
assert!(
app.discard_side_thread(&mut app_server, side_thread_id)
.await
);
assert_eq!(app.agent_navigation.get(&side_thread_id), None);
assert!(!app.side_threads.contains_key(&side_thread_id));
Ok(())
}
#[tokio::test]
async fn discard_side_thread_keeps_local_state_when_server_close_fails()
-> color_eyre::eyre::Result<()> {
let mut app = make_test_app().await;
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
let parent_thread_id = ThreadId::new();
let side_thread_id = ThreadId::new();
app.active_thread_id = Some(side_thread_id);
app.side_threads
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
app.agent_navigation.upsert(
side_thread_id,
Some("Side".to_string()),
Some("side".to_string()),
/*is_closed*/ false,
);
assert!(
!app.discard_side_thread(&mut app_server, side_thread_id)
.await
);
assert_eq!(app.active_thread_id, Some(side_thread_id));
assert_eq!(
app.side_threads
.get(&side_thread_id)
.map(|state| state.parent_thread_id),
Some(parent_thread_id)
);
assert!(app.agent_navigation.get(&side_thread_id).is_some());
Ok(())
}
#[tokio::test]
async fn discard_closed_side_thread_removes_local_state_without_server_rpc() {
let mut app = make_test_app().await;
let parent_thread_id = ThreadId::new();
let side_thread_id = ThreadId::new();
app.active_thread_id = Some(side_thread_id);
app.side_threads
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
app.thread_event_channels
.insert(side_thread_id, ThreadEventChannel::new(/*capacity*/ 4));
app.agent_navigation.upsert(
side_thread_id,
Some("Side".to_string()),
Some("side".to_string()),
/*is_closed*/ false,
);
app.discard_closed_side_thread(side_thread_id).await;
assert_eq!(app.active_thread_id, None);
assert!(!app.side_threads.contains_key(&side_thread_id));
assert!(!app.thread_event_channels.contains_key(&side_thread_id));
assert_eq!(app.agent_navigation.get(&side_thread_id), None);
}
}

View File

@@ -0,0 +1,405 @@
//! Shared test fixtures and builders for `crate::app` tests.
use super::*;
use crate::app_backtrack::BacktrackState;
use crate::chatwidget::tests::make_chatwidget_manual_with_sender;
use crate::file_search::FileSearchManager;
use crate::legacy_core::config::ConfigOverrides;
use crate::test_support::PathBufExt;
use crate::test_support::test_path_buf;
use codex_app_server_protocol::AgentMessageDeltaNotification;
use codex_app_server_protocol::CommandExecutionRequestApprovalParams;
use codex_app_server_protocol::HookCompletedNotification;
use codex_app_server_protocol::HookEventName as AppServerHookEventName;
use codex_app_server_protocol::HookExecutionMode as AppServerHookExecutionMode;
use codex_app_server_protocol::HookHandlerType as AppServerHookHandlerType;
use codex_app_server_protocol::HookOutputEntry as AppServerHookOutputEntry;
use codex_app_server_protocol::HookOutputEntryKind as AppServerHookOutputEntryKind;
use codex_app_server_protocol::HookRunStatus as AppServerHookRunStatus;
use codex_app_server_protocol::HookRunSummary as AppServerHookRunSummary;
use codex_app_server_protocol::HookScope as AppServerHookScope;
use codex_app_server_protocol::HookStartedNotification;
use codex_app_server_protocol::RequestId as AppServerRequestId;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ThreadClosedNotification;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadTokenUsage;
use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification;
use codex_app_server_protocol::TokenUsageBreakdown;
use codex_app_server_protocol::ToolRequestUserInputParams;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnCompletedNotification;
use codex_app_server_protocol::TurnStartedNotification;
use codex_app_server_protocol::TurnStatus;
use codex_otel::SessionTelemetry;
use codex_protocol::ThreadId;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_utils_absolute_path::AbsolutePathBuf;
use ratatui::prelude::Line;
use std::collections::HashMap;
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
pub(in crate::app) async fn make_test_app() -> App {
let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await;
let config = chat_widget.config_ref().clone();
let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone());
let model = crate::legacy_core::test_support::get_model_offline(config.model.as_deref());
let session_telemetry = test_session_telemetry(&config, model.as_str());
App {
model_catalog: chat_widget.model_catalog(),
session_telemetry,
app_event_tx,
chat_widget,
config,
active_profile: None,
cli_kv_overrides: Vec::new(),
harness_overrides: ConfigOverrides::default(),
runtime_approval_policy_override: None,
runtime_sandbox_policy_override: None,
file_search,
transcript_cells: Vec::new(),
overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
enhanced_keys_supported: false,
commit_anim_running: Arc::new(AtomicBool::new(false)),
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
backtrack: BacktrackState::default(),
backtrack_render_pending: false,
feedback: codex_feedback::CodexFeedback::new(),
feedback_audience: FeedbackAudience::External,
environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)),
remote_app_server_url: None,
remote_app_server_auth_token: None,
pending_update_action: None,
pending_shutdown_exit_thread_id: None,
windows_sandbox: WindowsSandboxState::default(),
thread_event_channels: HashMap::new(),
thread_event_listener_tasks: HashMap::new(),
agent_navigation: AgentNavigationState::default(),
side_threads: HashMap::new(),
active_thread_id: None,
active_thread_rx: None,
primary_thread_id: None,
last_subagent_backfill_attempt: None,
primary_session_configured: None,
pending_primary_events: VecDeque::new(),
pending_app_server_requests: PendingAppServerRequests::default(),
pending_plugin_enabled_writes: HashMap::new(),
}
}
pub(in crate::app) async fn make_test_app_with_channels() -> (
App,
tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
tokio::sync::mpsc::UnboundedReceiver<Op>,
) {
let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender().await;
let config = chat_widget.config_ref().clone();
let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone());
let model = crate::legacy_core::test_support::get_model_offline(config.model.as_deref());
let session_telemetry = test_session_telemetry(&config, model.as_str());
(
App {
model_catalog: chat_widget.model_catalog(),
session_telemetry,
app_event_tx,
chat_widget,
config,
active_profile: None,
cli_kv_overrides: Vec::new(),
harness_overrides: ConfigOverrides::default(),
runtime_approval_policy_override: None,
runtime_sandbox_policy_override: None,
file_search,
transcript_cells: Vec::new(),
overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
enhanced_keys_supported: false,
commit_anim_running: Arc::new(AtomicBool::new(false)),
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
backtrack: BacktrackState::default(),
backtrack_render_pending: false,
feedback: codex_feedback::CodexFeedback::new(),
feedback_audience: FeedbackAudience::External,
environment_manager: Arc::new(EnvironmentManager::new(/*exec_server_url*/ None)),
remote_app_server_url: None,
remote_app_server_auth_token: None,
pending_update_action: None,
pending_shutdown_exit_thread_id: None,
windows_sandbox: WindowsSandboxState::default(),
thread_event_channels: HashMap::new(),
thread_event_listener_tasks: HashMap::new(),
agent_navigation: AgentNavigationState::default(),
side_threads: HashMap::new(),
active_thread_id: None,
active_thread_rx: None,
primary_thread_id: None,
last_subagent_backfill_attempt: None,
primary_session_configured: None,
pending_primary_events: VecDeque::new(),
pending_app_server_requests: PendingAppServerRequests::default(),
pending_plugin_enabled_writes: HashMap::new(),
},
rx,
op_rx,
)
}
pub(in crate::app) fn test_absolute_path(path: &str) -> AbsolutePathBuf {
AbsolutePathBuf::try_from(PathBuf::from(path)).expect("absolute test path")
}
pub(in crate::app) fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState {
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(),
service_tier: None,
approval_policy: AskForApproval::Never,
approvals_reviewer: ApprovalsReviewer::User,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd: cwd.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()),
}
}
pub(in crate::app) fn test_turn(turn_id: &str, status: TurnStatus, items: Vec<ThreadItem>) -> Turn {
Turn {
id: turn_id.to_string(),
items,
status,
error: None,
started_at: None,
completed_at: None,
duration_ms: None,
}
}
pub(in crate::app) fn turn_started_notification(
thread_id: ThreadId,
turn_id: &str,
) -> ServerNotification {
ServerNotification::TurnStarted(TurnStartedNotification {
thread_id: thread_id.to_string(),
turn: Turn {
started_at: Some(0),
..test_turn(turn_id, TurnStatus::InProgress, Vec::new())
},
})
}
pub(in crate::app) fn turn_completed_notification(
thread_id: ThreadId,
turn_id: &str,
status: TurnStatus,
) -> ServerNotification {
ServerNotification::TurnCompleted(TurnCompletedNotification {
thread_id: thread_id.to_string(),
turn: Turn {
completed_at: Some(0),
duration_ms: Some(1),
..test_turn(turn_id, status, Vec::new())
},
})
}
pub(in crate::app) fn thread_closed_notification(thread_id: ThreadId) -> ServerNotification {
ServerNotification::ThreadClosed(ThreadClosedNotification {
thread_id: thread_id.to_string(),
})
}
pub(in crate::app) fn token_usage_notification(
thread_id: ThreadId,
turn_id: &str,
model_context_window: Option<i64>,
) -> ServerNotification {
ServerNotification::ThreadTokenUsageUpdated(ThreadTokenUsageUpdatedNotification {
thread_id: thread_id.to_string(),
turn_id: turn_id.to_string(),
token_usage: ThreadTokenUsage {
total: TokenUsageBreakdown {
total_tokens: 10,
input_tokens: 4,
cached_input_tokens: 1,
output_tokens: 5,
reasoning_output_tokens: 0,
},
last: TokenUsageBreakdown {
total_tokens: 10,
input_tokens: 4,
cached_input_tokens: 1,
output_tokens: 5,
reasoning_output_tokens: 0,
},
model_context_window,
},
})
}
pub(in crate::app) fn hook_started_notification(
thread_id: ThreadId,
turn_id: &str,
) -> ServerNotification {
ServerNotification::HookStarted(HookStartedNotification {
thread_id: thread_id.to_string(),
turn_id: Some(turn_id.to_string()),
run: AppServerHookRunSummary {
id: "user-prompt-submit:0:/tmp/hooks.json".to_string(),
event_name: AppServerHookEventName::UserPromptSubmit,
handler_type: AppServerHookHandlerType::Command,
execution_mode: AppServerHookExecutionMode::Sync,
scope: AppServerHookScope::Turn,
source_path: test_path_buf("/tmp/hooks.json").abs(),
source: codex_app_server_protocol::HookSource::User,
display_order: 0,
status: AppServerHookRunStatus::Running,
status_message: Some("checking go-workflow input policy".to_string()),
started_at: 1,
completed_at: None,
duration_ms: None,
entries: Vec::new(),
},
})
}
pub(in crate::app) fn hook_completed_notification(
thread_id: ThreadId,
turn_id: &str,
) -> ServerNotification {
ServerNotification::HookCompleted(HookCompletedNotification {
thread_id: thread_id.to_string(),
turn_id: Some(turn_id.to_string()),
run: AppServerHookRunSummary {
id: "user-prompt-submit:0:/tmp/hooks.json".to_string(),
event_name: AppServerHookEventName::UserPromptSubmit,
handler_type: AppServerHookHandlerType::Command,
execution_mode: AppServerHookExecutionMode::Sync,
scope: AppServerHookScope::Turn,
source_path: test_path_buf("/tmp/hooks.json").abs(),
source: codex_app_server_protocol::HookSource::User,
display_order: 0,
status: AppServerHookRunStatus::Stopped,
status_message: Some("checking go-workflow input policy".to_string()),
started_at: 1,
completed_at: Some(11),
duration_ms: Some(10),
entries: vec![
AppServerHookOutputEntry {
kind: AppServerHookOutputEntryKind::Warning,
text: "go-workflow must start from PlanMode".to_string(),
},
AppServerHookOutputEntry {
kind: AppServerHookOutputEntryKind::Stop,
text: "prompt blocked".to_string(),
},
],
},
})
}
pub(in crate::app) fn agent_message_delta_notification(
thread_id: ThreadId,
turn_id: &str,
item_id: &str,
delta: &str,
) -> ServerNotification {
ServerNotification::AgentMessageDelta(AgentMessageDeltaNotification {
thread_id: thread_id.to_string(),
turn_id: turn_id.to_string(),
item_id: item_id.to_string(),
delta: delta.to_string(),
})
}
pub(in crate::app) fn exec_approval_request(
thread_id: ThreadId,
turn_id: &str,
item_id: &str,
approval_id: Option<&str>,
) -> ServerRequest {
ServerRequest::CommandExecutionRequestApproval {
request_id: AppServerRequestId::Integer(1),
params: CommandExecutionRequestApprovalParams {
thread_id: thread_id.to_string(),
turn_id: turn_id.to_string(),
item_id: item_id.to_string(),
approval_id: approval_id.map(str::to_string),
reason: Some("needs approval".to_string()),
network_approval_context: None,
command: Some("echo hello".to_string()),
cwd: Some(test_path_buf("/tmp/project").abs()),
command_actions: None,
additional_permissions: None,
proposed_execpolicy_amendment: None,
proposed_network_policy_amendments: None,
available_decisions: None,
},
}
}
pub(in crate::app) fn request_user_input_request(
thread_id: ThreadId,
turn_id: &str,
item_id: &str,
) -> ServerRequest {
ServerRequest::ToolRequestUserInput {
request_id: AppServerRequestId::Integer(2),
params: ToolRequestUserInputParams {
thread_id: thread_id.to_string(),
turn_id: turn_id.to_string(),
item_id: item_id.to_string(),
questions: Vec::new(),
},
}
}
pub(in crate::app) fn lines_to_single_string(lines: &[Line<'_>]) -> String {
lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
fn test_session_telemetry(config: &Config, model: &str) -> SessionTelemetry {
let model_info = crate::legacy_core::test_support::construct_model_info_offline(model, config);
SessionTelemetry::new(
ThreadId::new(),
model,
model_info.slug.as_str(),
/*account_id*/ None,
/*account_email*/ None,
/*auth_mode*/ None,
"test_originator".to_string(),
/*log_user_prompts*/ false,
"test".to_string(),
SessionSource::Cli,
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -592,6 +592,60 @@ mod tests {
use ratatui::style::Color;
use ratatui::style::Modifier;
#[test]
fn agent_picker_item_name_snapshot() {
let thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread id");
let snapshot = [
format!(
"{} | {}",
format_agent_picker_item_name(
Some("Robie"),
Some("explorer"),
/*is_primary*/ true
),
thread_id
),
format!(
"{} | {}",
format_agent_picker_item_name(
Some("Robie"),
Some("explorer"),
/*is_primary*/ false
),
thread_id
),
format!(
"{} | {}",
format_agent_picker_item_name(
Some("Robie"),
/*agent_role*/ None,
/*is_primary*/ false
),
thread_id
),
format!(
"{} | {}",
format_agent_picker_item_name(
/*agent_nickname*/ None,
Some("explorer"),
/*is_primary*/ false
),
thread_id
),
format!(
"{} | {}",
format_agent_picker_item_name(
/*agent_nickname*/ None, /*agent_role*/ None,
/*is_primary*/ false
),
thread_id
),
]
.join("\n");
assert_snapshot!("agent_picker_item_name", snapshot);
}
#[test]
fn collab_events_snapshot() {
let sender_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000001")

View File

@@ -1,5 +1,5 @@
---
source: tui/src/app.rs
source: tui/src/multi_agents.rs
expression: snapshot
---
Main [default] | 00000000-0000-0000-0000-000000000123