mirror of
https://github.com/openai/codex.git
synced 2026-05-01 09:56:37 +00:00
Compare commits
2 Commits
codex-fix/
...
etraut/tui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbc2a2b2bd | ||
|
|
3003f069cb |
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
405
codex-rs/tui/src/app/test_support.rs
Normal file
405
codex-rs/tui/src/app/test_support.rs
Normal 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
@@ -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")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/app.rs
|
||||
source: tui/src/multi_agents.rs
|
||||
expression: snapshot
|
||||
---
|
||||
Main [default] | 00000000-0000-0000-0000-000000000123
|
||||
Reference in New Issue
Block a user