mirror of
https://github.com/openai/codex.git
synced 2026-11-15 21:50:46 +00:00
## What changed - Remove `Config::legacy_sandbox_policy` and `Permissions::legacy_sandbox_policy`. - Update core and TUI tests to use permission profiles and filesystem/network policies directly, with explicit conversion where legacy wire formats are required. - Report the active sandbox label in app-server resume override mismatch diagnostics. GitOrigin-RevId: cbc5417944e23404523e69dc5516d66d6bfafefe
1851 lines
68 KiB
Rust
1851 lines
68 KiB
Rust
use super::helpers::drain_insert_history_transcript;
|
|
use super::*;
|
|
use crate::app_event::HistoryLookupResponse;
|
|
use codex_app_server_protocol::ImageReference;
|
|
use codex_app_server_protocol::ThreadItem;
|
|
use codex_protocol::models::FunctionCallOutputBody;
|
|
use codex_protocol::models::ManagedFileSystemPermissions;
|
|
use codex_protocol::permissions::FileSystemAccessMode;
|
|
use codex_protocol::permissions::FileSystemPath;
|
|
use codex_protocol::permissions::FileSystemSandboxEntry;
|
|
use codex_protocol::permissions::FileSystemSpecialPath;
|
|
use codex_protocol::permissions::NetworkSandboxPolicy;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
#[tokio::test]
|
|
async fn resumed_initial_messages_render_history() {
|
|
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.set_feature_enabled(Feature::CliDaybreak, /*enabled*/ false);
|
|
|
|
let thread_id = ThreadId::new();
|
|
let rollout_file = NamedTempFile::new().unwrap();
|
|
let mut configured = crate::session_state::ThreadSessionState {
|
|
read_only_history: false,
|
|
daybreak_enabled: true,
|
|
windows_sandbox_host: crate::app::WindowsSandboxHost::Local,
|
|
thread_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: PermissionProfile::read_only(),
|
|
active_permission_profile: None,
|
|
cwd: test_path_buf("/home/user/project").abs(),
|
|
runtime_workspace_roots: Vec::new(),
|
|
instruction_source_paths: Vec::new(),
|
|
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
|
collaboration_mode: None,
|
|
message_history: None,
|
|
network_proxy: None,
|
|
rollout_path: Some(rollout_file.path().to_path_buf()),
|
|
};
|
|
|
|
// The first attachment can change project credentials even with an in-process server.
|
|
chat.status_account_display = Some(StatusAccountDisplay::ApiKey);
|
|
let mut model = crate::test_support::TEST_MODEL_PRESETS[0].clone();
|
|
model.available_access_programs = Some(codex_protocol::openai_models::ModelAccessPrograms {
|
|
cyber: vec![codex_protocol::turn_input::CyberAccessProgram::Standard],
|
|
});
|
|
chat.model_catalog = Arc::new(ModelCatalog::new(vec![model]));
|
|
chat.handle_thread_session(configured.clone());
|
|
assert!(
|
|
chat.model_catalog
|
|
.models
|
|
.iter()
|
|
.all(|model| model.available_access_programs.is_none()),
|
|
"access from the launch directory must not carry over to the attached project"
|
|
);
|
|
assert!(!chat.daybreak_enabled);
|
|
insta::assert_snapshot!(
|
|
drain_insert_history(&mut rx).into_iter().flatten()
|
|
.map(|line| line.to_string())
|
|
.filter(|line| line.contains("Daybreak"))
|
|
.collect::<Vec<_>>().join("\n"),
|
|
@""
|
|
);
|
|
|
|
// A saved On preference cannot bypass the API-key gate.
|
|
chat.status_account_display = Some(StatusAccountDisplay::ApiKey);
|
|
chat.set_feature_enabled(Feature::ApiKeyCyberAccessPrograms, /*enabled*/ false);
|
|
chat.set_feature_enabled(Feature::CliDaybreak, /*enabled*/ true);
|
|
configured.thread_id = ThreadId::new();
|
|
chat.handle_thread_session(configured.clone());
|
|
assert!(!chat.daybreak_enabled);
|
|
insta::assert_snapshot!(
|
|
drain_insert_history(&mut rx).into_iter().flatten()
|
|
.map(|line| line.to_string())
|
|
.filter(|line| line.contains("Daybreak"))
|
|
.collect::<Vec<_>>().join("\n"),
|
|
@""
|
|
);
|
|
|
|
// Session hydration can precede the account update on ChatGPT sign-in.
|
|
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.set_feature_enabled(Feature::CliDaybreak, /*enabled*/ true);
|
|
chat.show_welcome_banner = false;
|
|
chat.local_settings.tui.show_tooltips = false;
|
|
chat.config.cwd = configured.cwd.clone();
|
|
chat.transcript.active_cell = Some(ChatWidget::placeholder_session_header_cell(&chat.config));
|
|
configured.thread_id = ThreadId::new();
|
|
chat.handle_thread_session(configured);
|
|
assert!(chat.daybreak_enabled);
|
|
chat.update_account_state(
|
|
/*status_account_display*/ None, /*plan_type*/ None,
|
|
/*has_chatgpt_account*/ true, /*has_codex_backend_auth*/ true,
|
|
);
|
|
assert!(chat.daybreak_enabled);
|
|
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 history = drain_insert_history(&mut rx)
|
|
.iter()
|
|
.flatten()
|
|
.map(ToString::to_string)
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
.replace(CODEX_CLI_VERSION, "<VERSION>")
|
|
.replace(
|
|
&test_path_display("/home/user/project"),
|
|
"/home/user/project",
|
|
);
|
|
insta::assert_snapshot!("resumed_daybreak_banner", history);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replayed_failed_turns_preserve_overload_warnings_between_retries() {
|
|
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
let prompt = "The workspace also looks super confusing with its separator.";
|
|
let error_message = "Selected model is at capacity. Please try a different model.";
|
|
let failed_turn = |turn_id: &str, item_id: &str| AppServerTurn {
|
|
items: vec![ThreadItem::untimed(
|
|
AppServerThreadItemPayload::UserMessage {
|
|
id: item_id.to_string(),
|
|
client_id: None,
|
|
content: vec![AppServerUserInput::Text {
|
|
text: prompt.to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
},
|
|
)],
|
|
..app_server_turn(
|
|
turn_id,
|
|
AppServerTurnStatus::Failed,
|
|
/*duration_ms*/ None,
|
|
/*error*/
|
|
Some(AppServerTurnError {
|
|
misalignment: None,
|
|
message: error_message.to_string(),
|
|
codex_error_info: Some(CodexErrorInfo::ServerOverloaded),
|
|
additional_details: None,
|
|
}),
|
|
)
|
|
};
|
|
|
|
chat.replay_thread_turns(
|
|
vec![
|
|
failed_turn("turn-1", "user-1"),
|
|
failed_turn("turn-2", "user-2"),
|
|
],
|
|
ReplayKind::ResumeInitialMessages,
|
|
);
|
|
|
|
let rendered = drain_insert_history_transcript(&mut rx)
|
|
.into_iter()
|
|
.map(|lines| lines_to_single_string(&lines))
|
|
.collect::<String>();
|
|
|
|
assert_eq!(rendered.matches(prompt).count(), 2);
|
|
assert_eq!(rendered.matches(error_message).count(), 2);
|
|
insta::assert_snapshot!(
|
|
"replayed_failed_turns_preserve_overload_warnings_between_retries",
|
|
rendered
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn restored_conversation_ultra_remains_selected_after_switching_to_plan() {
|
|
let (mut chat, _rx, _ops) = make_chatwidget_manual(Some("gpt-5.4")).await;
|
|
chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
|
|
chat.set_plan_mode_reasoning_effort(Some(ReasoningEffortConfig::High));
|
|
|
|
chat.handle_thread_session(crate::session_state::ThreadSessionState {
|
|
read_only_history: false,
|
|
daybreak_enabled: false,
|
|
windows_sandbox_host: crate::app::WindowsSandboxHost::Local,
|
|
thread_id: ThreadId::new(),
|
|
forked_from_id: None,
|
|
fork_parent_title: None,
|
|
thread_name: None,
|
|
model: "gpt-5.4".to_string(),
|
|
model_provider_id: "test-provider".to_string(),
|
|
service_tier: None,
|
|
approval_policy: AskForApproval::Never,
|
|
approvals_reviewer: ApprovalsReviewer::User,
|
|
permission_profile: PermissionProfile::read_only(),
|
|
active_permission_profile: None,
|
|
cwd: test_path_buf("/home/user/project").abs(),
|
|
runtime_workspace_roots: Vec::new(),
|
|
instruction_source_paths: Vec::new(),
|
|
reasoning_effort: Some(ReasoningEffortConfig::Ultra),
|
|
collaboration_mode: None,
|
|
message_history: None,
|
|
network_proxy: None,
|
|
rollout_path: None,
|
|
});
|
|
chat.handle_key_event(KeyEvent::from(KeyCode::BackTab));
|
|
|
|
assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);
|
|
assert_eq!(
|
|
chat.current_reasoning_effort(),
|
|
Some(ReasoningEffortConfig::Ultra)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replayed_user_messages_seed_composer_history() {
|
|
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.bottom_pane.set_history_metadata(
|
|
ThreadId::new(),
|
|
/*log_id*/ 1,
|
|
/*entry_count*/ 3,
|
|
);
|
|
|
|
let mut replay_mention = |id: &str, text: &str, name: &str, path: &str| {
|
|
replay_user_message_inputs(
|
|
&mut chat,
|
|
id,
|
|
vec![
|
|
AppServerUserInput::Text {
|
|
text: text.to_string(),
|
|
text_elements: Vec::new(),
|
|
},
|
|
AppServerUserInput::Mention {
|
|
name: name.to_string(),
|
|
path: path.to_string(),
|
|
},
|
|
],
|
|
ReplayKind::ResumeInitialMessages,
|
|
);
|
|
};
|
|
replay_mention(
|
|
"user-1",
|
|
"use $sample",
|
|
"Sample Plugin",
|
|
"plugin://sample@test",
|
|
);
|
|
replay_mention(
|
|
"user-2",
|
|
"use $google-calendar",
|
|
"Google Calendar",
|
|
"app://google_calendar",
|
|
);
|
|
drain_insert_history(&mut rx);
|
|
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
|
assert_eq!(chat.bottom_pane.composer_text(), "use $google-calendar");
|
|
assert_eq!(
|
|
chat.bottom_pane.take_mention_bindings(),
|
|
vec![MentionBinding {
|
|
sigil: '$',
|
|
mention: "google-calendar".to_string(),
|
|
path: "app://google_calendar".to_string(),
|
|
}]
|
|
);
|
|
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
|
assert_eq!(chat.bottom_pane.composer_text(), "use $sample");
|
|
assert_eq!(
|
|
chat.bottom_pane.take_mention_bindings(),
|
|
vec![MentionBinding {
|
|
sigil: '$',
|
|
mention: "sample".to_string(),
|
|
path: "plugin://sample@test".to_string(),
|
|
}]
|
|
);
|
|
|
|
let mut next_lookup_offset = || {
|
|
let AppEvent::LookupMessageHistoryEntry { offset, .. } =
|
|
rx.try_recv().expect("expected lookup")
|
|
else {
|
|
panic!("unexpected event variant");
|
|
};
|
|
offset
|
|
};
|
|
let response = |offset, entry: &str| HistoryLookupResponse::Entry {
|
|
offset,
|
|
log_id: 1,
|
|
entry: Some(entry.to_string()),
|
|
};
|
|
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
|
chat.handle_history_entry_response(response(
|
|
next_lookup_offset(),
|
|
"use [$google-calendar](app://google_calendar)",
|
|
));
|
|
|
|
assert_eq!(next_lookup_offset(), 1);
|
|
chat.handle_history_entry_response(response(1, "use [$sample](plugin://sample@test)"));
|
|
|
|
assert_eq!(next_lookup_offset(), 0);
|
|
chat.handle_history_entry_response(response(0, "/rename smoke-1"));
|
|
assert_eq!(chat.bottom_pane.composer_text(), "/rename smoke-1");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replayed_review_prompt_does_not_seed_composer_history() {
|
|
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
chat.replay_thread_item(
|
|
AppServerThreadItemPayload::EnteredReviewMode {
|
|
id: "review-start".to_string(),
|
|
review: "changes against main".to_string(),
|
|
},
|
|
"turn-1".to_string(),
|
|
ReplayKind::ResumeInitialMessages,
|
|
);
|
|
replay_user_message_text(
|
|
&mut chat,
|
|
"review-prompt",
|
|
"Review the code changes against the base branch 'main'.",
|
|
ReplayKind::ResumeInitialMessages,
|
|
);
|
|
drain_insert_history(&mut rx);
|
|
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
|
assert_eq!(chat.bottom_pane.composer_text(), "");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replayed_delegated_tool_output_is_attributed_without_seeding_composer_history() {
|
|
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
let item = AppServerThreadItemPayload::FunctionCallOutput {
|
|
id: "delegation-1".to_string(),
|
|
name: "send_message_to_thread".to_string(),
|
|
namespace: Some("codex_tui".to_string()),
|
|
output: FunctionCallOutputBody::Text(
|
|
concat!(
|
|
"<codex_delegation>\n <source_thread_id>source-task</source_thread_id>\n <input>",
|
|
"**Follow up** on [the PR](https://example.com/pr).\n\n",
|
|
"- Review `<up> & report`.\n",
|
|
"- Share the result.",
|
|
"</input>\n</codex_delegation>",
|
|
)
|
|
.to_string(),
|
|
),
|
|
};
|
|
|
|
chat.handle_server_notification(
|
|
ServerNotification::ItemCompleted(ItemCompletedNotification::new(
|
|
"thread-1".to_string(),
|
|
"turn-1".to_string(),
|
|
item.clone(),
|
|
/*started_at_ms*/ None,
|
|
/*completed_at_ms*/ 0,
|
|
)),
|
|
/*replay_kind*/ None,
|
|
);
|
|
let live = drain_insert_history(&mut rx);
|
|
|
|
chat.replay_thread_item(
|
|
item.clone(),
|
|
"turn-1".to_string(),
|
|
ReplayKind::ResumeInitialMessages,
|
|
);
|
|
let cells = drain_insert_history(&mut rx);
|
|
assert_eq!(cells.len(), 1);
|
|
assert_eq!(live, cells);
|
|
assert_chatwidget_snapshot!(
|
|
"replayed_delegated_tool_output",
|
|
lines_to_single_string(&cells[0])
|
|
);
|
|
|
|
let projected = crate::thread_transcript::thread_items_to_transcript_cells(
|
|
/*thread_id*/ None,
|
|
&chat.config.cwd,
|
|
[item],
|
|
crate::thread_transcript::RawReasoningVisibility::Hidden,
|
|
/*config*/ None,
|
|
);
|
|
assert_eq!(
|
|
projected
|
|
.iter()
|
|
.map(|cell| cell.transcript_lines(/*width*/ 80))
|
|
.collect::<Vec<_>>(),
|
|
cells,
|
|
);
|
|
assert_eq!(
|
|
projected[0]
|
|
.transcript_hyperlink_lines(/*width*/ 80)
|
|
.iter()
|
|
.flat_map(|line| &line.hyperlinks)
|
|
.map(|link| link.destination.as_str())
|
|
.collect::<std::collections::BTreeSet<_>>(),
|
|
std::collections::BTreeSet::from(["https://example.com/pr"]),
|
|
);
|
|
let narrow = projected[0].transcript_hyperlink_lines(/*width*/ 32);
|
|
let first = narrow[0].source.as_ref().unwrap();
|
|
let continuation = narrow[1].source.as_ref().unwrap();
|
|
assert!(std::sync::Arc::ptr_eq(&first.text, &continuation.text));
|
|
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
|
assert_eq!(chat.bottom_pane.composer_text(), "");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replayed_nested_review_prompts_do_not_render_or_seed_composer_history() {
|
|
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
let review_hint = "current changes";
|
|
let review_prompt = "Review the current code changes (staged, unstaged, and untracked files).";
|
|
let user_message = |id: &str, text: &str| AppServerThreadItemPayload::UserMessage {
|
|
id: id.to_string(),
|
|
client_id: None,
|
|
content: vec![AppServerUserInput::Text {
|
|
text: text.to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
};
|
|
let review_marker = |turn_id: &str| AppServerTurn {
|
|
items: vec![
|
|
ThreadItem::untimed(AppServerThreadItemPayload::EnteredReviewMode {
|
|
id: format!("{turn_id}-start"),
|
|
review: review_hint.to_string(),
|
|
}),
|
|
ThreadItem::untimed(AppServerThreadItemPayload::ExitedReviewMode {
|
|
id: format!("{turn_id}-end"),
|
|
review: "review complete".to_string(),
|
|
}),
|
|
],
|
|
..app_server_turn(
|
|
turn_id,
|
|
AppServerTurnStatus::Completed,
|
|
/*duration_ms*/ None,
|
|
/*error*/ None,
|
|
)
|
|
};
|
|
|
|
chat.replay_thread_turns(
|
|
vec![
|
|
review_marker("turn-review-before-steer"),
|
|
AppServerTurn {
|
|
items: vec![
|
|
ThreadItem::untimed(user_message("interrupted-prompt", review_hint)),
|
|
ThreadItem::untimed(user_message("interrupted-steer", review_hint)),
|
|
],
|
|
completed_at: Some(1),
|
|
..app_server_turn(
|
|
"turn-interrupted",
|
|
AppServerTurnStatus::Interrupted,
|
|
/*duration_ms*/ None,
|
|
/*error*/ None,
|
|
)
|
|
},
|
|
review_marker("turn-review"),
|
|
AppServerTurn {
|
|
items: vec![
|
|
ThreadItem::untimed(user_message("review-prompt-1", review_prompt)),
|
|
ThreadItem::untimed(user_message("review-prompt-2", review_prompt)),
|
|
ThreadItem::untimed(AppServerThreadItemPayload::AgentMessage {
|
|
moderation: None,
|
|
id: "review-result".to_string(),
|
|
text: "review result is retained".to_string(),
|
|
phase: Some(MessagePhase::FinalAnswer),
|
|
memory_citation: None,
|
|
delivery: None,
|
|
questions: None,
|
|
}),
|
|
],
|
|
..app_server_turn(
|
|
"turn-review-child",
|
|
AppServerTurnStatus::Interrupted,
|
|
/*duration_ms*/ None,
|
|
/*error*/ None,
|
|
)
|
|
},
|
|
],
|
|
ReplayKind::ResumeInitialMessages,
|
|
);
|
|
|
|
let rendered = drain_insert_history(&mut rx)
|
|
.into_iter()
|
|
.map(|lines| lines_to_single_string(&lines))
|
|
.collect::<String>();
|
|
assert!(!rendered.contains(review_prompt));
|
|
assert_eq!(rendered.matches(review_hint).count(), 4);
|
|
insta::assert_snapshot!("replayed_nested_review_prompts", rendered);
|
|
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
|
assert_eq!(chat.bottom_pane.composer_text(), review_hint);
|
|
}
|
|
|
|
#[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;
|
|
|
|
let placeholder = "[Image #1]";
|
|
let message = format!("{placeholder} replayed");
|
|
let text_elements = vec![TextElement::new(
|
|
(0..placeholder.len()).into(),
|
|
Some(placeholder.to_string()),
|
|
)];
|
|
let local_images = vec![PathBuf::from("/tmp/replay.png")];
|
|
|
|
let thread_id = ThreadId::new();
|
|
let rollout_file = NamedTempFile::new().unwrap();
|
|
let configured = crate::session_state::ThreadSessionState {
|
|
read_only_history: false,
|
|
daybreak_enabled: false,
|
|
windows_sandbox_host: crate::app::WindowsSandboxHost::Local,
|
|
thread_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: PermissionProfile::read_only(),
|
|
active_permission_profile: None,
|
|
cwd: test_path_buf("/home/user/project").abs(),
|
|
runtime_workspace_roots: Vec::new(),
|
|
instruction_source_paths: Vec::new(),
|
|
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
|
collaboration_mode: None,
|
|
message_history: None,
|
|
network_proxy: None,
|
|
rollout_path: Some(rollout_file.path().to_path_buf()),
|
|
};
|
|
|
|
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(),
|
|
detail: None,
|
|
},
|
|
],
|
|
ReplayKind::ResumeInitialMessages,
|
|
);
|
|
|
|
let mut user_cell = None;
|
|
while let Ok(ev) = rx.try_recv() {
|
|
if let AppEvent::InsertHistoryCell(cell) = ev
|
|
&& let Some(cell) = cell.as_any().downcast_ref::<UserHistoryCell>()
|
|
{
|
|
user_cell = Some((
|
|
cell.message.clone(),
|
|
cell.text_elements.clone(),
|
|
cell.local_image_paths.clone(),
|
|
cell.remote_image_urls.clone(),
|
|
));
|
|
break;
|
|
}
|
|
}
|
|
|
|
let (stored_message, stored_elements, stored_images, stored_remote_image_urls) =
|
|
user_cell.expect("expected a replayed user history cell");
|
|
assert_eq!(stored_message, message);
|
|
assert_eq!(stored_elements, text_elements);
|
|
assert_eq!(stored_images, local_images);
|
|
assert!(stored_remote_image_urls.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replayed_user_message_preserves_remote_image_urls() {
|
|
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
let message = "replayed with remote image".to_string();
|
|
let remote_image_urls = vec!["https://example.com/image.png".to_string()];
|
|
|
|
let thread_id = ThreadId::new();
|
|
let rollout_file = NamedTempFile::new().unwrap();
|
|
let configured = crate::session_state::ThreadSessionState {
|
|
read_only_history: false,
|
|
daybreak_enabled: false,
|
|
windows_sandbox_host: crate::app::WindowsSandboxHost::Local,
|
|
thread_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: PermissionProfile::read_only(),
|
|
active_permission_profile: None,
|
|
cwd: test_path_buf("/home/user/project").abs(),
|
|
runtime_workspace_roots: Vec::new(),
|
|
instruction_source_paths: Vec::new(),
|
|
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
|
collaboration_mode: None,
|
|
message_history: None,
|
|
network_proxy: None,
|
|
rollout_path: Some(rollout_file.path().to_path_buf()),
|
|
};
|
|
|
|
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 {
|
|
image: ImageReference::Inline {
|
|
url: remote_image_urls[0].clone(),
|
|
},
|
|
detail: None,
|
|
},
|
|
],
|
|
ReplayKind::ResumeInitialMessages,
|
|
);
|
|
|
|
let mut user_cell = None;
|
|
while let Ok(ev) = rx.try_recv() {
|
|
if let AppEvent::InsertHistoryCell(cell) = ev
|
|
&& let Some(cell) = cell.as_any().downcast_ref::<UserHistoryCell>()
|
|
{
|
|
user_cell = Some((
|
|
cell.message.clone(),
|
|
cell.local_image_paths.clone(),
|
|
cell.remote_image_urls.clone(),
|
|
));
|
|
break;
|
|
}
|
|
}
|
|
|
|
let (stored_message, stored_local_images, stored_remote_image_urls) =
|
|
user_cell.expect("expected a replayed user history cell");
|
|
assert_eq!(stored_message, message);
|
|
assert!(stored_local_images.is_empty());
|
|
assert_eq!(stored_remote_image_urls, remote_image_urls);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_configured_syncs_widget_config_permissions_and_cwd() {
|
|
let (mut chat, _rx, mut ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
chat.config
|
|
.permissions
|
|
.approval_policy
|
|
.set(AskForApproval::OnRequest.to_core())
|
|
.expect("set approval policy");
|
|
chat.config
|
|
.permissions
|
|
.set_permission_profile(PermissionProfile::workspace_write())
|
|
.expect("set permission profile");
|
|
chat.config.cwd = test_path_buf("/home/user/main").abs();
|
|
|
|
let expected_cwd = test_path_buf("/home/user/sub-agent").abs();
|
|
let expected_app_server_permission_profile = PermissionProfile::Managed {
|
|
network: NetworkSandboxPolicy::Restricted,
|
|
file_system: ManagedFileSystemPermissions::Restricted {
|
|
entries: vec![
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::Special {
|
|
value: FileSystemSpecialPath::Root,
|
|
},
|
|
access: FileSystemAccessMode::Read,
|
|
missing_path_behavior: None,
|
|
},
|
|
FileSystemSandboxEntry {
|
|
path: FileSystemPath::GlobPattern {
|
|
pattern: "**/.secret".to_string(),
|
|
},
|
|
access: FileSystemAccessMode::Deny,
|
|
missing_path_behavior: None,
|
|
},
|
|
],
|
|
glob_scan_max_depth: None,
|
|
},
|
|
};
|
|
let expected_permission_profile = expected_app_server_permission_profile.clone();
|
|
let configured = crate::session_state::ThreadSessionState {
|
|
read_only_history: false,
|
|
daybreak_enabled: false,
|
|
windows_sandbox_host: crate::app::WindowsSandboxHost::Local,
|
|
thread_id: ThreadId::new(),
|
|
forked_from_id: None,
|
|
fork_parent_title: None,
|
|
thread_name: None,
|
|
model: "test-model".to_string(),
|
|
model_provider_id: "test-provider".to_string(),
|
|
service_tier: None,
|
|
approval_policy: AskForApproval::Never,
|
|
approvals_reviewer: ApprovalsReviewer::User,
|
|
permission_profile: expected_permission_profile,
|
|
active_permission_profile: None,
|
|
cwd: expected_cwd.clone(),
|
|
runtime_workspace_roots: vec![expected_cwd.clone()],
|
|
instruction_source_paths: Vec::new(),
|
|
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
|
collaboration_mode: None,
|
|
message_history: None,
|
|
network_proxy: None,
|
|
rollout_path: None,
|
|
};
|
|
|
|
chat.handle_thread_session(configured);
|
|
|
|
assert_eq!(
|
|
AskForApproval::from(chat.config_ref().permissions.approval_policy.value()),
|
|
AskForApproval::Never
|
|
);
|
|
assert_eq!(
|
|
chat.config_ref().permissions.effective_permission_profile(),
|
|
expected_app_server_permission_profile
|
|
);
|
|
assert_eq!(&chat.config_ref().cwd, &expected_cwd);
|
|
while ops.try_recv().is_ok() {}
|
|
chat.handle_manage_skills_closed();
|
|
assert!(matches!(
|
|
ops.try_recv(),
|
|
Ok(AppCommand::ListSkills { cwds, force_reload: true })
|
|
if cwds == vec![expected_cwd.to_path_buf()]
|
|
));
|
|
|
|
let updated_profile = PermissionProfile::workspace_write();
|
|
chat.set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::legacy(
|
|
updated_profile.clone(),
|
|
))
|
|
.expect("set permission profile");
|
|
assert_eq!(
|
|
chat.config_ref().permissions.permission_profile(),
|
|
&updated_profile,
|
|
"local permission changes should replace SessionConfigured canonical permissions"
|
|
);
|
|
assert_eq!(
|
|
chat.config_ref().permissions.effective_permission_profile(),
|
|
updated_profile
|
|
.materialize_project_roots_with_workspace_roots(std::slice::from_ref(&expected_cwd,)),
|
|
"effective permissions should still use the current thread runtime workspace roots"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_configured_preserves_profile_workspace_roots() {
|
|
let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
let previous_cwd = test_path_buf("/home/user/main").abs();
|
|
let profile_root = test_path_buf("/home/user/shared").abs();
|
|
chat.config.cwd = previous_cwd.clone();
|
|
chat.config.workspace_roots = vec![previous_cwd, profile_root.clone()];
|
|
chat.config.workspace_roots_explicit = false;
|
|
chat.config
|
|
.permissions
|
|
.set_workspace_roots(chat.config.workspace_roots.clone());
|
|
|
|
let session_cwd = test_path_buf("/home/user/sub-agent").abs();
|
|
let session_runtime_workspace_roots = vec![session_cwd.clone()];
|
|
let session_effective_workspace_roots = vec![session_cwd.clone(), profile_root];
|
|
let session_permission_profile = PermissionProfile::workspace_write()
|
|
.materialize_project_roots_with_workspace_roots(&session_effective_workspace_roots);
|
|
let configured = crate::session_state::ThreadSessionState {
|
|
read_only_history: false,
|
|
daybreak_enabled: false,
|
|
windows_sandbox_host: crate::app::WindowsSandboxHost::Local,
|
|
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: session_permission_profile.clone(),
|
|
active_permission_profile: None,
|
|
cwd: session_cwd.clone(),
|
|
runtime_workspace_roots: session_runtime_workspace_roots.clone(),
|
|
instruction_source_paths: Vec::new(),
|
|
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
|
collaboration_mode: None,
|
|
message_history: None,
|
|
network_proxy: None,
|
|
rollout_path: None,
|
|
};
|
|
|
|
chat.handle_thread_session(configured);
|
|
|
|
assert_eq!(&chat.config_ref().cwd, &session_cwd);
|
|
assert_eq!(
|
|
chat.config_ref().permissions.user_visible_workspace_roots(),
|
|
session_runtime_workspace_roots.as_slice()
|
|
);
|
|
assert_eq!(
|
|
chat.config_ref().permissions.effective_permission_profile(),
|
|
session_permission_profile
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn session_configured_external_sandbox_keeps_external_runtime_policy() {
|
|
let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
let expected_app_server_permission_profile = PermissionProfile::External {
|
|
network: NetworkSandboxPolicy::Restricted,
|
|
};
|
|
let expected_permission_profile = expected_app_server_permission_profile.clone();
|
|
let configured = crate::session_state::ThreadSessionState {
|
|
read_only_history: false,
|
|
daybreak_enabled: false,
|
|
windows_sandbox_host: crate::app::WindowsSandboxHost::Local,
|
|
thread_id: ThreadId::new(),
|
|
forked_from_id: None,
|
|
fork_parent_title: None,
|
|
thread_name: None,
|
|
model: "test-model".to_string(),
|
|
model_provider_id: "test-provider".to_string(),
|
|
service_tier: None,
|
|
approval_policy: AskForApproval::Never,
|
|
approvals_reviewer: ApprovalsReviewer::User,
|
|
permission_profile: expected_permission_profile,
|
|
active_permission_profile: None,
|
|
cwd: test_path_buf("/home/user/external").abs(),
|
|
runtime_workspace_roots: Vec::new(),
|
|
instruction_source_paths: Vec::new(),
|
|
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
|
collaboration_mode: None,
|
|
message_history: None,
|
|
network_proxy: None,
|
|
rollout_path: None,
|
|
};
|
|
|
|
chat.handle_thread_session(configured);
|
|
|
|
assert_eq!(
|
|
chat.config_ref().permissions.effective_permission_profile(),
|
|
expected_app_server_permission_profile
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replayed_user_message_with_only_remote_images_renders_history_cell() {
|
|
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
let remote_image_urls = vec!["https://example.com/remote-only.png".to_string()];
|
|
|
|
let thread_id = ThreadId::new();
|
|
let rollout_file = NamedTempFile::new().unwrap();
|
|
let configured = crate::session_state::ThreadSessionState {
|
|
read_only_history: false,
|
|
daybreak_enabled: false,
|
|
windows_sandbox_host: crate::app::WindowsSandboxHost::Local,
|
|
thread_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: PermissionProfile::read_only(),
|
|
active_permission_profile: None,
|
|
cwd: test_path_buf("/home/user/project").abs(),
|
|
runtime_workspace_roots: Vec::new(),
|
|
instruction_source_paths: Vec::new(),
|
|
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
|
collaboration_mode: None,
|
|
message_history: None,
|
|
network_proxy: None,
|
|
rollout_path: Some(rollout_file.path().to_path_buf()),
|
|
};
|
|
|
|
chat.handle_thread_session(configured);
|
|
replay_user_message_inputs(
|
|
&mut chat,
|
|
"user-1",
|
|
vec![AppServerUserInput::Image {
|
|
image: ImageReference::Inline {
|
|
url: remote_image_urls[0].clone(),
|
|
},
|
|
detail: None,
|
|
}],
|
|
ReplayKind::ResumeInitialMessages,
|
|
);
|
|
|
|
let mut user_cell = None;
|
|
while let Ok(ev) = rx.try_recv() {
|
|
if let AppEvent::InsertHistoryCell(cell) = ev
|
|
&& let Some(cell) = cell.as_any().downcast_ref::<UserHistoryCell>()
|
|
{
|
|
user_cell = Some((cell.message.clone(), cell.remote_image_urls.clone()));
|
|
break;
|
|
}
|
|
}
|
|
|
|
let (stored_message, stored_remote_image_urls) =
|
|
user_cell.expect("expected a replayed remote-image-only user history cell");
|
|
assert!(stored_message.is_empty());
|
|
assert_eq!(stored_remote_image_urls, remote_image_urls);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replayed_user_message_with_only_local_images_renders_history_cell() {
|
|
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
let local_images = [PathBuf::from("/tmp/replay-local-only.png")];
|
|
|
|
let thread_id = ThreadId::new();
|
|
let rollout_file = NamedTempFile::new().unwrap();
|
|
let configured = crate::session_state::ThreadSessionState {
|
|
read_only_history: false,
|
|
daybreak_enabled: false,
|
|
windows_sandbox_host: crate::app::WindowsSandboxHost::Local,
|
|
thread_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: PermissionProfile::read_only(),
|
|
active_permission_profile: None,
|
|
cwd: test_path_buf("/home/user/project").abs(),
|
|
runtime_workspace_roots: Vec::new(),
|
|
instruction_source_paths: Vec::new(),
|
|
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
|
collaboration_mode: None,
|
|
message_history: None,
|
|
network_proxy: None,
|
|
rollout_path: Some(rollout_file.path().to_path_buf()),
|
|
};
|
|
|
|
chat.handle_thread_session(configured);
|
|
replay_user_message_inputs(
|
|
&mut chat,
|
|
"user-1",
|
|
vec![AppServerUserInput::LocalImage {
|
|
path: local_images[0].clone(),
|
|
detail: None,
|
|
}],
|
|
ReplayKind::ResumeInitialMessages,
|
|
);
|
|
|
|
let mut user_cell = None;
|
|
while let Ok(ev) = rx.try_recv() {
|
|
if let AppEvent::InsertHistoryCell(cell) = ev
|
|
&& let Some(cell) = cell.as_any().downcast_ref::<UserHistoryCell>()
|
|
{
|
|
user_cell = Some((cell.message.clone(), cell.local_image_paths.clone()));
|
|
break;
|
|
}
|
|
}
|
|
|
|
let (stored_message, stored_local_images) =
|
|
user_cell.expect("expected a replayed local-image-only user history cell");
|
|
assert!(stored_message.is_empty());
|
|
assert_eq!(stored_local_images, local_images);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn forked_thread_history_line_includes_name_and_id_snapshot() {
|
|
let (chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
let mut chat = chat;
|
|
|
|
let forked_from_id =
|
|
ThreadId::from_string("e9f18a88-8081-4e51-9d4e-8af5cde2d8dd").expect("forked id");
|
|
|
|
chat.emit_forked_thread_event(forked_from_id, Some("named-thread".to_string()));
|
|
|
|
let history_cell = tokio::time::timeout(std::time::Duration::from_secs(2), async {
|
|
loop {
|
|
match rx.recv().await {
|
|
Some(AppEvent::InsertHistoryCell(cell)) => break cell,
|
|
Some(_) => continue,
|
|
None => panic!("app event channel closed before forked thread history was emitted"),
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.expect("timed out waiting for forked thread history");
|
|
let combined = lines_to_single_string(&history_cell.display_lines(/*width*/ 80));
|
|
|
|
assert!(
|
|
combined.contains("Thread forked from"),
|
|
"expected forked thread message in history"
|
|
);
|
|
assert_chatwidget_snapshot!("forked_thread_history_line", combined);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn forked_thread_history_line_without_name_shows_id_once_snapshot() {
|
|
let (chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
let mut chat = chat;
|
|
let temp = tempdir().expect("tempdir");
|
|
chat.config.codex_home =
|
|
codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(temp.path())
|
|
.expect("temp dir is absolute");
|
|
|
|
let forked_from_id =
|
|
ThreadId::from_string("019c2d47-4935-7423-a190-05691f566092").expect("forked id");
|
|
chat.emit_forked_thread_event(forked_from_id, /*fork_parent_title*/ None);
|
|
|
|
let history_cell = tokio::time::timeout(std::time::Duration::from_secs(2), async {
|
|
loop {
|
|
match rx.recv().await {
|
|
Some(AppEvent::InsertHistoryCell(cell)) => break cell,
|
|
Some(_) => continue,
|
|
None => panic!("app event channel closed before forked thread history was emitted"),
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.expect("timed out waiting for forked thread history");
|
|
let combined = lines_to_single_string(&history_cell.display_lines(/*width*/ 80));
|
|
|
|
assert_chatwidget_snapshot!("forked_thread_history_line_without_name", combined);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn prompt_edit_stops_streaming_without_submitting_queued_input() {
|
|
let (mut chat, _events, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.on_task_started();
|
|
chat.on_agent_message_delta("removed response".into());
|
|
chat.queue_user_message("queued follow-up".into());
|
|
chat.enter_review_mode_with_hint("review".into(), /*from_replay*/ false);
|
|
|
|
let retained_turn = AppServerTurn {
|
|
items: vec![
|
|
ThreadItem::untimed(AppServerThreadItemPayload::AgentMessage {
|
|
moderation: None,
|
|
id: "retained-response".to_string(),
|
|
text: "retained response".to_string(),
|
|
phase: Some(MessagePhase::FinalAnswer),
|
|
memory_citation: None,
|
|
delivery: None,
|
|
questions: None,
|
|
}),
|
|
ThreadItem::untimed(AppServerThreadItemPayload::UserMessage {
|
|
id: "voice-steer".to_string(),
|
|
client_id: None,
|
|
content: vec![UserInput::Text {
|
|
text: "<realtime_delegation><input>voice input</input></realtime_delegation>"
|
|
.to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
}),
|
|
ThreadItem::untimed(AppServerThreadItemPayload::AgentMessage {
|
|
moderation: None,
|
|
id: "private-commentary".to_string(),
|
|
text: "hidden voice commentary".to_string(),
|
|
phase: Some(MessagePhase::Commentary),
|
|
memory_citation: None,
|
|
delivery: None,
|
|
questions: None,
|
|
}),
|
|
],
|
|
..app_server_turn(
|
|
"retained-turn",
|
|
AppServerTurnStatus::Completed,
|
|
/*duration_ms*/ None,
|
|
/*error*/ None,
|
|
)
|
|
};
|
|
chat.reset_after_prompt_revert(/*rollout_path*/ None, &[retained_turn]);
|
|
chat.pre_draw_tick();
|
|
|
|
assert_eq!(
|
|
(
|
|
chat.is_user_turn_pending_or_running(),
|
|
chat.has_active_stream_tail(),
|
|
chat.maybe_send_next_queued_input(),
|
|
chat.last_agent_markdown_text(),
|
|
),
|
|
(false, false, false, Some("retained response")),
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn prompt_edit_thread_history_line_snapshot() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
chat.emit_prompt_edit_thread_event();
|
|
|
|
let history_cell = tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 2), async {
|
|
loop {
|
|
match rx.recv().await {
|
|
Some(AppEvent::InsertHistoryCell(cell)) => break cell,
|
|
Some(_) => continue,
|
|
None => panic!("app event channel closed before prompt edit history was emitted"),
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.expect("timed out waiting for prompt edit history");
|
|
let combined = lines_to_single_string(&history_cell.display_lines(/*width*/ 80));
|
|
|
|
assert_chatwidget_snapshot!("prompt_edit_thread_history_line", combined);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn app_server_forked_thread_history_line_uses_app_server_title_snapshot() {
|
|
let (chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
let mut chat = chat;
|
|
let temp = tempdir().expect("tempdir");
|
|
chat.config.codex_home =
|
|
codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(temp.path())
|
|
.expect("temp dir is absolute");
|
|
|
|
let forked_from_id =
|
|
ThreadId::from_string("e9f18a88-8081-4e51-9d4e-8af5cde2d8dd").expect("forked id");
|
|
let session_index_entry = format!(
|
|
"{{\"id\":\"{forked_from_id}\",\"thread_name\":\"stale-local-thread\",\"updated_at\":\"2024-01-02T00:00:00Z\"}}\n"
|
|
);
|
|
std::fs::write(temp.path().join("session_index.jsonl"), session_index_entry)
|
|
.expect("write session index");
|
|
|
|
chat.emit_forked_thread_event(forked_from_id, Some("app-server-parent-thread".to_string()));
|
|
|
|
let history_cell = tokio::time::timeout(std::time::Duration::from_secs(2), async {
|
|
loop {
|
|
match rx.recv().await {
|
|
Some(AppEvent::InsertHistoryCell(cell)) => break cell,
|
|
Some(_) => continue,
|
|
None => panic!("app event channel closed before forked thread history was emitted"),
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.expect("timed out waiting for forked thread history");
|
|
let combined = lines_to_single_string(&history_cell.display_lines(/*width*/ 80));
|
|
|
|
assert!(combined.contains("app-server-parent-thread"));
|
|
assert!(
|
|
!combined.contains("stale-local-thread"),
|
|
"app-server fork title lookup should not read local CODEX_HOME"
|
|
);
|
|
assert_chatwidget_snapshot!("app_server_forked_thread_history_line", combined);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn app_server_forked_thread_history_line_without_app_server_name_ignores_local_snapshot() {
|
|
let (chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
let mut chat = chat;
|
|
let temp = tempdir().expect("tempdir");
|
|
chat.config.codex_home =
|
|
codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(temp.path())
|
|
.expect("temp dir is absolute");
|
|
|
|
let forked_from_id =
|
|
ThreadId::from_string("019c2d47-4935-7423-a190-05691f566092").expect("forked id");
|
|
let session_index_entry = format!(
|
|
"{{\"id\":\"{forked_from_id}\",\"thread_name\":\"stale-local-thread\",\"updated_at\":\"2024-01-02T00:00:00Z\"}}\n"
|
|
);
|
|
std::fs::write(temp.path().join("session_index.jsonl"), session_index_entry)
|
|
.expect("write session index");
|
|
|
|
chat.emit_forked_thread_event(forked_from_id, /*fork_parent_title*/ None);
|
|
|
|
let history_cell = tokio::time::timeout(std::time::Duration::from_secs(2), async {
|
|
loop {
|
|
match rx.recv().await {
|
|
Some(AppEvent::InsertHistoryCell(cell)) => break cell,
|
|
Some(_) => continue,
|
|
None => panic!("app event channel closed before forked thread history was emitted"),
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.expect("timed out waiting for forked thread history");
|
|
let combined = lines_to_single_string(&history_cell.display_lines(/*width*/ 80));
|
|
|
|
assert!(
|
|
!combined.contains("stale-local-thread"),
|
|
"app-server fork title lookup should not read local CODEX_HOME"
|
|
);
|
|
assert_chatwidget_snapshot!(
|
|
"app_server_forked_thread_history_line_without_app_server_name",
|
|
combined
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_snapshot_replay_preserves_agent_message_during_review_mode() {
|
|
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
replay_entered_review_mode(&mut chat, "current changes");
|
|
let _ = drain_insert_history(&mut rx);
|
|
|
|
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_retryable_app_server_error_keeps_turn_running() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
chat.handle_server_notification(
|
|
ServerNotification::TurnStarted(TurnStartedNotification {
|
|
thread_id: "thread-1".to_string(),
|
|
turn: AppServerTurn {
|
|
id: "turn-1".to_string(),
|
|
root_turn_id: None,
|
|
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
|
items: Vec::new(),
|
|
status: AppServerTurnStatus::InProgress,
|
|
error: None,
|
|
started_at: Some(0),
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
},
|
|
}),
|
|
Some(ReplayKind::ThreadSnapshot),
|
|
);
|
|
drain_insert_history(&mut rx);
|
|
|
|
chat.handle_server_notification(
|
|
ServerNotification::Error(ErrorNotification {
|
|
error: AppServerTurnError {
|
|
misalignment: None,
|
|
message: "Reconnecting... 1/5".to_string(),
|
|
codex_error_info: None,
|
|
additional_details: Some("Idle timeout waiting for SSE".to_string()),
|
|
},
|
|
will_retry: true,
|
|
thread_id: "thread-1".to_string(),
|
|
turn_id: "turn-1".to_string(),
|
|
}),
|
|
Some(ReplayKind::ThreadSnapshot),
|
|
);
|
|
|
|
assert!(drain_insert_history(&mut rx).is_empty());
|
|
assert!(chat.bottom_pane.is_task_running());
|
|
let status = chat
|
|
.bottom_pane
|
|
.status_widget()
|
|
.expect("status indicator should be visible");
|
|
assert_eq!(status.header(), "Working");
|
|
assert_eq!(status.details(), None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replayed_thread_closed_notification_does_not_exit_tui() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
|
|
chat.handle_server_notification(
|
|
ServerNotification::ThreadClosed(ThreadClosedNotification {
|
|
thread_id: "thread-1".to_string(),
|
|
}),
|
|
Some(ReplayKind::ThreadSnapshot),
|
|
);
|
|
|
|
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replayed_reasoning_item_preserves_summary_parts_and_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_thread_session(crate::session_state::ThreadSessionState {
|
|
read_only_history: false,
|
|
daybreak_enabled: false,
|
|
windows_sandbox_host: crate::app::WindowsSandboxHost::Local,
|
|
thread_id: ThreadId::new(),
|
|
forked_from_id: None,
|
|
fork_parent_title: None,
|
|
thread_name: None,
|
|
model: "test-model".to_string(),
|
|
model_provider_id: "test-provider".to_string(),
|
|
service_tier: None,
|
|
approval_policy: AskForApproval::Never,
|
|
approvals_reviewer: ApprovalsReviewer::User,
|
|
permission_profile: PermissionProfile::read_only(),
|
|
active_permission_profile: None,
|
|
cwd: test_project_path().abs(),
|
|
runtime_workspace_roots: Vec::new(),
|
|
instruction_source_paths: Vec::new(),
|
|
reasoning_effort: None,
|
|
collaboration_mode: None,
|
|
message_history: None,
|
|
network_proxy: None,
|
|
rollout_path: None,
|
|
});
|
|
let _ = drain_insert_history(&mut rx);
|
|
|
|
chat.replay_thread_item(
|
|
AppServerThreadItemPayload::Reasoning {
|
|
id: "reasoning-1".to_string(),
|
|
summary: vec![
|
|
"**Plan**\n\ndone".to_string(),
|
|
"**Checking tests**\n\n<!-- -->".to_string(),
|
|
],
|
|
content: vec!["Raw reasoning".to_string()],
|
|
},
|
|
"turn-1".to_string(),
|
|
ReplayKind::ThreadSnapshot,
|
|
);
|
|
|
|
let rendered = match rx.try_recv() {
|
|
Ok(AppEvent::InsertHistoryCell(cell)) => {
|
|
lines_to_single_string(&cell.transcript_lines(/*width*/ 80))
|
|
}
|
|
other => panic!("expected InsertHistoryCell, got {other:?}"),
|
|
};
|
|
assert_eq!(rendered, "• done\n");
|
|
assert!(!rendered.contains("Raw reasoning"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
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_thread_session(crate::session_state::ThreadSessionState {
|
|
read_only_history: false,
|
|
daybreak_enabled: false,
|
|
windows_sandbox_host: crate::app::WindowsSandboxHost::Local,
|
|
thread_id: ThreadId::new(),
|
|
forked_from_id: None,
|
|
fork_parent_title: None,
|
|
thread_name: None,
|
|
model: "test-model".to_string(),
|
|
model_provider_id: "test-provider".to_string(),
|
|
service_tier: None,
|
|
approval_policy: AskForApproval::Never,
|
|
approvals_reviewer: ApprovalsReviewer::User,
|
|
permission_profile: PermissionProfile::read_only(),
|
|
active_permission_profile: None,
|
|
cwd: test_project_path().abs(),
|
|
runtime_workspace_roots: Vec::new(),
|
|
instruction_source_paths: Vec::new(),
|
|
reasoning_effort: None,
|
|
collaboration_mode: None,
|
|
message_history: None,
|
|
network_proxy: None,
|
|
rollout_path: None,
|
|
});
|
|
let _ = drain_insert_history(&mut rx);
|
|
|
|
chat.replay_thread_item(
|
|
AppServerThreadItemPayload::Reasoning {
|
|
id: "reasoning-1".to_string(),
|
|
summary: vec!["Summary only".to_string()],
|
|
content: vec!["Raw reasoning".to_string()],
|
|
},
|
|
"turn-1".to_string(),
|
|
ReplayKind::ThreadSnapshot,
|
|
);
|
|
|
|
let rendered = match rx.try_recv() {
|
|
Ok(AppEvent::InsertHistoryCell(cell)) => {
|
|
lines_to_single_string(&cell.transcript_lines(/*width*/ 80))
|
|
}
|
|
other => panic!("expected InsertHistoryCell, got {other:?}"),
|
|
};
|
|
assert!(rendered.contains("Raw reasoning"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replayed_mcp_tool_call_stays_active_until_completion() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
let _ = drain_insert_history(&mut rx);
|
|
|
|
let mut item = AppServerThreadItemPayload::McpToolCall {
|
|
environment_id: None,
|
|
id: "mcp-1".to_string(),
|
|
server: "copilot-bridge".to_string(),
|
|
tool: "copilot".to_string(),
|
|
status: codex_app_server_protocol::McpToolCallStatus::InProgress,
|
|
arguments: json!({"action": "wait"}),
|
|
app_context: None,
|
|
mcp_app_resource_uri: None,
|
|
mcp_app_ui: None,
|
|
plugin_id: None,
|
|
read_only_hint: None,
|
|
result: None,
|
|
error: None,
|
|
duration_ms: None,
|
|
};
|
|
chat.replay_thread_item(
|
|
item.clone(),
|
|
"turn-1".to_string(),
|
|
ReplayKind::ThreadSnapshot,
|
|
);
|
|
|
|
assert!(drain_insert_history(&mut rx).is_empty());
|
|
let active = active_blob(&chat);
|
|
assert!(active.contains("Calling"));
|
|
assert!(!active.contains("MCP tool call completed without a result"));
|
|
|
|
if let AppServerThreadItemPayload::McpToolCall { status, .. } = &mut item {
|
|
*status = codex_app_server_protocol::McpToolCallStatus::Completed;
|
|
}
|
|
chat.on_mcp_tool_call_completed(item);
|
|
assert!(chat.transcript.active_cell.is_none());
|
|
let completed = drain_insert_history(&mut rx);
|
|
assert_eq!(completed.len(), 1);
|
|
assert!(
|
|
lines_to_single_string(&completed[0]).contains("MCP tool call completed without a result")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn failed_repl_mcp_tool_call_preserves_status_and_result() {
|
|
for server in ["node_repl", "cua_repl"] {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
let _ = drain_insert_history(&mut rx);
|
|
|
|
chat.handle_server_notification(
|
|
ServerNotification::ItemCompleted(ItemCompletedNotification::new("thread-1".to_string(), "turn-1".to_string(), AppServerThreadItemPayload::McpToolCall {
|
|
environment_id: None,
|
|
id: "mcp-failed".to_string(),
|
|
server: server.to_string(),
|
|
tool: "js".to_string(),
|
|
status: codex_app_server_protocol::McpToolCallStatus::Failed,
|
|
arguments: json!({"title": "Inspect workspace"}),
|
|
app_context: None,
|
|
mcp_app_resource_uri: None,
|
|
mcp_app_ui: None,
|
|
plugin_id: None,
|
|
read_only_hint: None,
|
|
result: Some(Box::new(codex_app_server_protocol::McpToolCallResult {
|
|
content: vec![
|
|
json!({"type": "text", "text": "Script failed"}),
|
|
json!({"type": "text", "text": r#"{"exit_code":0,"output":"ready","chunk_id":"chunk-1"}"#}),
|
|
json!({"type": "text", "text": "Script error:\npermission denied"}),
|
|
],
|
|
structured_content: None,
|
|
meta: None,
|
|
})),
|
|
error: None,
|
|
duration_ms: Some(5),
|
|
}, /*started_at_ms*/ None, /*completed_at_ms*/ 0)),
|
|
/*replay_kind*/ None,
|
|
);
|
|
|
|
chat.flush_active_cell();
|
|
let cells = drain_insert_history(&mut rx);
|
|
let [lines] = cells.as_slice() else {
|
|
panic!("expected one completed MCP tool call for {server}");
|
|
};
|
|
if server == "cua_repl" {
|
|
insta::assert_snapshot!("failed_computer_activity", lines_to_single_string(lines));
|
|
continue;
|
|
}
|
|
insta::allow_duplicates! {
|
|
insta::assert_snapshot!(lines_to_single_string(lines), @r#"
|
|
• Inspect workspace
|
|
└ Script failed
|
|
{"exit_code": 0, "output": "ready", "chunk_id": "chunk-1"}
|
|
Script error:
|
|
+1 line (⌃t to view transcript)
|
|
"#);
|
|
}
|
|
assert_eq!(
|
|
lines.first(),
|
|
Some(&Line::from(vec![
|
|
"•".red().bold(),
|
|
" ".into(),
|
|
"Inspect workspace".fg(crate::style::accent_color()),
|
|
])),
|
|
"{server}",
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn deferred_mcp_lifecycle_events_keep_fifo_after_stream_finishes() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
let cwd = chat.config.cwd.to_path_buf();
|
|
chat.stream_controller = Some(crate::streaming::controller::StreamController::new(
|
|
/*width*/ Some(80),
|
|
cwd.as_path(),
|
|
chat.history_render_mode(),
|
|
));
|
|
|
|
chat.on_mcp_tool_call_started(AppServerThreadItemPayload::McpToolCall {
|
|
environment_id: None,
|
|
id: "mcp-deferred".to_string(),
|
|
server: "copilot-bridge".to_string(),
|
|
tool: "copilot".to_string(),
|
|
status: codex_app_server_protocol::McpToolCallStatus::InProgress,
|
|
arguments: json!({"action": "wait"}),
|
|
app_context: None,
|
|
mcp_app_resource_uri: None,
|
|
mcp_app_ui: None,
|
|
plugin_id: None,
|
|
read_only_hint: None,
|
|
result: None,
|
|
error: None,
|
|
duration_ms: None,
|
|
});
|
|
assert!(!chat.interrupts.is_empty());
|
|
|
|
chat.stream_controller = None;
|
|
chat.on_mcp_tool_call_completed(AppServerThreadItemPayload::McpToolCall {
|
|
environment_id: None,
|
|
id: "mcp-deferred".to_string(),
|
|
server: "copilot-bridge".to_string(),
|
|
tool: "copilot".to_string(),
|
|
status: codex_app_server_protocol::McpToolCallStatus::Completed,
|
|
arguments: json!({"action": "wait"}),
|
|
app_context: None,
|
|
mcp_app_resource_uri: None,
|
|
mcp_app_ui: None,
|
|
plugin_id: None,
|
|
read_only_hint: None,
|
|
result: Some(Box::new(codex_app_server_protocol::McpToolCallResult {
|
|
content: vec![json!({"type": "text", "text": "deferred result"})],
|
|
structured_content: None,
|
|
meta: None,
|
|
})),
|
|
error: None,
|
|
duration_ms: Some(5),
|
|
});
|
|
|
|
assert!(!chat.interrupts.is_empty());
|
|
assert!(drain_insert_history(&mut rx).is_empty());
|
|
|
|
chat.flush_interrupt_queue();
|
|
|
|
assert!(chat.interrupts.is_empty());
|
|
assert!(chat.transcript.active_cell.is_none());
|
|
let rendered = drain_insert_history(&mut rx)
|
|
.into_iter()
|
|
.map(|lines| lines_to_single_string(&lines))
|
|
.collect::<String>();
|
|
assert!(rendered.contains("deferred result"), "{rendered}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.show_welcome_banner = false;
|
|
|
|
chat.handle_server_notification(
|
|
ServerNotification::TurnStarted(TurnStartedNotification {
|
|
thread_id: "thread-1".to_string(),
|
|
turn: AppServerTurn {
|
|
id: "turn-1".to_string(),
|
|
root_turn_id: None,
|
|
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
|
items: Vec::new(),
|
|
status: AppServerTurnStatus::InProgress,
|
|
error: None,
|
|
started_at: Some(0),
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
},
|
|
}),
|
|
/*replay_kind*/ None,
|
|
);
|
|
let _ = drain_insert_history(&mut rx);
|
|
|
|
handle_agent_reasoning_started(&mut chat, "reasoning-1");
|
|
|
|
chat.handle_server_notification(
|
|
ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification {
|
|
thread_id: "thread-1".to_string(),
|
|
turn_id: "turn-1".to_string(),
|
|
item_id: "reasoning-1".to_string(),
|
|
delta: "Summary only".to_string(),
|
|
summary_index: 0,
|
|
}),
|
|
/*replay_kind*/ None,
|
|
);
|
|
|
|
chat.handle_server_notification(
|
|
ServerNotification::ItemCompleted(ItemCompletedNotification::new(
|
|
"thread-1".to_string(),
|
|
"turn-1".to_string(),
|
|
AppServerThreadItemPayload::Reasoning {
|
|
id: "reasoning-1".to_string(),
|
|
summary: vec!["Summary only".to_string()],
|
|
content: Vec::new(),
|
|
},
|
|
/*started_at_ms*/ None,
|
|
/*completed_at_ms*/ 0,
|
|
)),
|
|
/*replay_kind*/ None,
|
|
);
|
|
|
|
let rendered = match rx.try_recv() {
|
|
Ok(AppEvent::InsertHistoryCell(cell)) => {
|
|
lines_to_single_string(&cell.transcript_lines(/*width*/ 80))
|
|
}
|
|
other => panic!("expected InsertHistoryCell, got {other:?}"),
|
|
};
|
|
assert_eq!(rendered.matches("Summary only").count(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn live_reasoning_summary_drops_empty_parts_without_losing_content() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.show_welcome_banner = false;
|
|
|
|
chat.handle_server_notification(
|
|
ServerNotification::TurnStarted(TurnStartedNotification {
|
|
thread_id: "thread-1".to_string(),
|
|
turn: AppServerTurn {
|
|
id: "turn-1".to_string(),
|
|
root_turn_id: None,
|
|
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
|
items: Vec::new(),
|
|
status: AppServerTurnStatus::InProgress,
|
|
error: None,
|
|
started_at: Some(0),
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
},
|
|
}),
|
|
/*replay_kind*/ None,
|
|
);
|
|
let _ = drain_insert_history(&mut rx);
|
|
|
|
handle_agent_reasoning_started(&mut chat, "reasoning-1");
|
|
|
|
for (summary_index, delta) in [
|
|
(0, "**Plan**\n\ndone"),
|
|
(1, "**Checking tests**\n\n<!-- -->"),
|
|
] {
|
|
chat.handle_server_notification(
|
|
ServerNotification::ReasoningSummaryPartAdded(
|
|
codex_app_server_protocol::ReasoningSummaryPartAddedNotification {
|
|
thread_id: "thread-1".to_string(),
|
|
turn_id: "turn-1".to_string(),
|
|
item_id: "reasoning-1".to_string(),
|
|
summary_index,
|
|
},
|
|
),
|
|
/*replay_kind*/ None,
|
|
);
|
|
chat.handle_server_notification(
|
|
ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification {
|
|
thread_id: "thread-1".to_string(),
|
|
turn_id: "turn-1".to_string(),
|
|
item_id: "reasoning-1".to_string(),
|
|
delta: delta.to_string(),
|
|
summary_index,
|
|
}),
|
|
/*replay_kind*/ None,
|
|
);
|
|
}
|
|
|
|
chat.handle_server_notification(
|
|
ServerNotification::ItemCompleted(ItemCompletedNotification::new(
|
|
"thread-1".to_string(),
|
|
"turn-1".to_string(),
|
|
AppServerThreadItemPayload::Reasoning {
|
|
id: "reasoning-1".to_string(),
|
|
summary: vec![
|
|
"**Plan**\n\ndone".to_string(),
|
|
"**Checking tests**\n\n<!-- -->".to_string(),
|
|
],
|
|
content: Vec::new(),
|
|
},
|
|
/*started_at_ms*/ None,
|
|
/*completed_at_ms*/ 0,
|
|
)),
|
|
/*replay_kind*/ None,
|
|
);
|
|
|
|
let rendered = match rx.try_recv() {
|
|
Ok(AppEvent::InsertHistoryCell(cell)) => {
|
|
lines_to_single_string(&cell.transcript_lines(/*width*/ 80))
|
|
}
|
|
other => panic!("expected InsertHistoryCell, got {other:?}"),
|
|
};
|
|
assert_eq!(rendered, "• done\n");
|
|
}
|
|
|
|
#[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;
|
|
|
|
let started_at = chrono::Utc::now().timestamp() - 125;
|
|
chat.handle_server_notification(
|
|
ServerNotification::TurnStarted(TurnStartedNotification {
|
|
thread_id: "thread-1".to_string(),
|
|
turn: AppServerTurn {
|
|
started_at: Some(started_at),
|
|
..app_server_turn(
|
|
"turn-1",
|
|
AppServerTurnStatus::InProgress,
|
|
/*duration_ms*/ None,
|
|
/*error*/ None,
|
|
)
|
|
},
|
|
}),
|
|
Some(ReplayKind::ThreadSnapshot),
|
|
);
|
|
|
|
drain_insert_history(&mut rx);
|
|
assert!(chat.bottom_pane.is_task_running());
|
|
let before = chrono::Utc::now().timestamp() - started_at;
|
|
let elapsed = chat.bottom_pane.status_elapsed().unwrap().as_secs() as i64;
|
|
let after = chrono::Utc::now().timestamp() - started_at;
|
|
// The wire timestamp has second precision; reads may be separated by any CI delay.
|
|
assert!(
|
|
(before - 1..=after).contains(&elapsed),
|
|
"{elapsed} not in {before}..={after}"
|
|
);
|
|
let timer = regex_lite::Regex::new(r"\(\d+(?:h \d+m \d+s|m \d+s|s) •").unwrap();
|
|
insta::assert_snapshot!(
|
|
timer.replace_all(render_bottom_first_row(&chat, /*width*/ 80).trim(), "(<elapsed> •"),
|
|
@"• Working (<elapsed> • esc to interrupt)"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replayed_in_progress_turn_marks_task_running() {
|
|
for replay_kind in [
|
|
ReplayKind::ResumeInitialMessages,
|
|
ReplayKind::ThreadSnapshot,
|
|
] {
|
|
for items_view in [
|
|
codex_app_server_protocol::TurnItemsView::Full,
|
|
codex_app_server_protocol::TurnItemsView::NotLoaded,
|
|
] {
|
|
let (mut chat, mut rx, mut op_rx) =
|
|
make_chatwidget_manual(/*model_override*/ None).await;
|
|
chat.thread_id = Some(ThreadId::new());
|
|
let started_at = chrono::Utc::now().timestamp() - 125;
|
|
chat.replay_thread_turns(
|
|
vec![AppServerTurn {
|
|
items_view,
|
|
started_at: Some(started_at),
|
|
..app_server_turn(
|
|
"turn-1",
|
|
AppServerTurnStatus::InProgress,
|
|
/*duration_ms*/ None,
|
|
/*error*/ None,
|
|
)
|
|
}],
|
|
replay_kind,
|
|
);
|
|
|
|
assert!(drain_insert_history(&mut rx).is_empty());
|
|
assert_eq!(
|
|
(
|
|
chat.bottom_pane.is_task_running(),
|
|
chat.turn_lifecycle.last_turn_id.as_deref(),
|
|
),
|
|
(true, Some("turn-1")),
|
|
);
|
|
let before = chrono::Utc::now().timestamp() - started_at;
|
|
let elapsed = chat.bottom_pane.status_elapsed().unwrap().as_secs() as i64;
|
|
let after = chrono::Utc::now().timestamp() - started_at;
|
|
assert!(
|
|
(before - 1..=after).contains(&elapsed),
|
|
"{elapsed} not in {before}..={after}"
|
|
);
|
|
let timer = regex_lite::Regex::new(r"\(\d+(?:h \d+m \d+s|m \d+s|s) •").unwrap();
|
|
assert_chatwidget_snapshot!(
|
|
"replayed_in_progress_turn",
|
|
timer.replace_all(
|
|
&normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 80)),
|
|
"(<elapsed> •"
|
|
),
|
|
);
|
|
|
|
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
|
|
assert_matches!(op_rx.try_recv(), Ok(Op::Interrupt));
|
|
assert!(
|
|
std::iter::from_fn(|| rx.try_recv().ok())
|
|
.all(|event| !matches!(event, AppEvent::Exit(_)))
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
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());
|
|
|
|
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!(
|
|
cells.is_empty(),
|
|
"expected no history cell for replayed StreamError event"
|
|
);
|
|
assert_eq!(chat.status_state.current_status.header, "Idle");
|
|
assert!(chat.status_state.retry_status_header.is_none());
|
|
assert!(chat.bottom_pane.status_widget().is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
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;
|
|
|
|
replay_turn_started(&mut chat, ReplayKind::ThreadSnapshot);
|
|
drain_insert_history(&mut rx);
|
|
|
|
handle_stream_error_with_replay(
|
|
&mut chat,
|
|
"Reconnecting... 1/5",
|
|
/*additional_details*/ None,
|
|
Some(ReplayKind::ThreadSnapshot),
|
|
);
|
|
drain_insert_history(&mut rx);
|
|
|
|
replay_agent_message_delta(&mut chat, "hello", ReplayKind::ThreadSnapshot);
|
|
|
|
assert_eq!(chat.status_state.current_status.header, "Working");
|
|
assert_eq!(chat.status_state.current_status.details, None);
|
|
assert!(chat.status_state.retry_status_header.is_none());
|
|
assert!(chat.bottom_pane.status_widget().is_none());
|
|
assert!(chat.active_cell_is_stream_tail());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stream_recovery_restores_previous_status_header() {
|
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
|
handle_turn_started(&mut chat, "turn-1");
|
|
drain_insert_history(&mut rx);
|
|
handle_stream_error(
|
|
&mut chat,
|
|
"Reconnecting... 1/5",
|
|
/*additional_details*/ None,
|
|
);
|
|
drain_insert_history(&mut rx);
|
|
handle_agent_message_delta(&mut chat, "hello");
|
|
|
|
assert_eq!(chat.status_state.current_status.header, "Working");
|
|
assert_eq!(chat.status_state.current_status.details, None);
|
|
assert!(chat.status_state.retry_status_header.is_none());
|
|
assert!(chat.bottom_pane.status_widget().is_none());
|
|
assert!(chat.active_cell_is_stream_tail());
|
|
}
|