mirror of
https://github.com/openai/codex.git
synced 2026-11-03 19:53:25 +00:00
Restore agent navigation after TUI reconnects (#41918)
## What changed - Keep the agent command center visible with its cached inventory and draft while reconnecting, then refresh it without losing the selected task. - Allow cached, unavailable conversations to reattach when selected while preserving their drafts, recap progress, and runtime permissions. - Report an unsubmitted rename when its target disappears during refresh, and continue to quarantine queued input instead of resending it automatically. ## Testing - Add a Unix socket replacement test covering command-center recovery with and without an active conversation, failed reconnects, inventory changes, and subsequent live task input. GitOrigin-RevId: 774aca2dfb8358e9abd6a2e2ef0d509646648f66
This commit is contained in:
@@ -34,6 +34,7 @@ pub(super) struct AgentsOverviewState {
|
||||
pub(super) refresh_pending: bool,
|
||||
pub(super) rendered_full_screen: bool,
|
||||
pub(super) visible_thread_ids: Vec<ThreadId>,
|
||||
pub(super) threads: Vec<Thread>,
|
||||
pub(super) view_state:
|
||||
Arc<std::sync::Mutex<super::agents_overview_view::AgentsOverviewViewState>>,
|
||||
pub(super) input_states: HashMap<ThreadId, ThreadInputState>,
|
||||
@@ -93,7 +94,10 @@ impl App {
|
||||
|
||||
self.agents_overview.request_id = None;
|
||||
self.agents_overview.refresh_pending = false;
|
||||
let view = self.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
|
||||
let view = self.agents_overview_view(
|
||||
self.agents_overview.threads.clone(),
|
||||
/*selected_thread_id*/ None,
|
||||
);
|
||||
self.agents_overview.visible_thread_ids = view.thread_ids();
|
||||
self.chat_widget.show_bottom_pane_view(Box::new(view));
|
||||
self.refresh_agents_overview_threads(app_server);
|
||||
@@ -240,6 +244,7 @@ impl App {
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.agents_overview.threads = threads.clone();
|
||||
let view = self.agents_overview_view(threads, selected_thread_id);
|
||||
self.agents_overview.visible_thread_ids = view.thread_ids();
|
||||
if selected_thread_id
|
||||
@@ -247,6 +252,13 @@ impl App {
|
||||
&& let Ok(mut state) = self.agents_overview.view_state.lock()
|
||||
&& state.renaming
|
||||
{
|
||||
self.chat_widget.add_info_message(
|
||||
format!(
|
||||
"The rename target disappeared. Unsubmitted title: {}",
|
||||
state.input
|
||||
),
|
||||
/*hint*/ None,
|
||||
);
|
||||
state.renaming = false;
|
||||
state.input.clear();
|
||||
}
|
||||
@@ -320,7 +332,9 @@ impl App {
|
||||
app_server: &mut AppServerSession,
|
||||
root_thread_id: ThreadId,
|
||||
) -> color_eyre::Result<AppRunControl> {
|
||||
if self.current_displayed_thread_id() == Some(root_thread_id) {
|
||||
if self.current_displayed_thread_id() == Some(root_thread_id)
|
||||
&& !self.thread_unavailable(root_thread_id)
|
||||
{
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
|
||||
@@ -505,7 +519,9 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
if self.current_displayed_thread_id() != Some(root_thread_id) {
|
||||
if self.current_displayed_thread_id() != Some(root_thread_id)
|
||||
|| self.thread_unavailable(root_thread_id)
|
||||
{
|
||||
self.select_agent_thread_and_discard_side(tui, app_server, root_thread_id)
|
||||
.await?;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! TUI-only out-of-process reconnection. Each attempt initializes a fresh client and rejoins existing
|
||||
//! threads using ordinary resume/history semantics; no user operation is retried.
|
||||
//! Offline input and old async completions are quarantined. Cached conversations stay read-only
|
||||
//! until they can be reattached; command-center recovery is handled separately.
|
||||
//! Offline input and old async completions are quarantined.
|
||||
|
||||
use super::*;
|
||||
use crate::app_server_session::ResumeModelSettings;
|
||||
@@ -113,6 +112,36 @@ pub(super) async fn reconnect(
|
||||
}
|
||||
|
||||
impl App {
|
||||
// Preserve local choices for future input, without replaying failed settings writes or
|
||||
// changing the server's authorization for work that was already admitted.
|
||||
pub(super) fn restore_runtime_permissions(
|
||||
&self,
|
||||
session: &mut ThreadSessionState,
|
||||
cached: &ThreadSessionState,
|
||||
) {
|
||||
if self.current_displayed_thread_id() != Some(session.thread_id)
|
||||
&& self.primary_thread_id != Some(session.thread_id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Side conversations can replace the app-wide overrides. Only copy choices
|
||||
// that still match this conversation's own cached settings.
|
||||
if let Some(policy) = self.runtime_approval_policy_override
|
||||
&& policy == cached.approval_policy
|
||||
{
|
||||
session.approval_policy = policy;
|
||||
}
|
||||
if let Some(profile) = &self.runtime_permission_profile_override
|
||||
&& profile.permission_profile == cached.permission_profile
|
||||
&& profile.active_permission_profile == cached.active_permission_profile
|
||||
&& self.config.approvals_reviewer == cached.approvals_reviewer
|
||||
{
|
||||
session.permission_profile = profile.permission_profile.clone();
|
||||
session.active_permission_profile = profile.active_permission_profile.clone();
|
||||
session.approvals_reviewer = self.config.approvals_reviewer;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn thread_unavailable(&self, id: ThreadId) -> bool {
|
||||
!matches!(self.app_server_target, AppServerTarget::Embedded)
|
||||
&& self
|
||||
@@ -146,7 +175,7 @@ impl App {
|
||||
.is_some()
|
||||
{
|
||||
if let Ok(mut state) = self.agents_overview.view_state.lock() {
|
||||
state.connection_notice = Some("Disconnected — agent list is stale");
|
||||
state.connection_notice = Some("Reconnecting — agent list is stale");
|
||||
if let Some(task) = state.refresh_task.take() {
|
||||
task.abort();
|
||||
}
|
||||
@@ -181,6 +210,10 @@ impl App {
|
||||
bootstrap,
|
||||
thread,
|
||||
} = connected;
|
||||
let selected = self
|
||||
.chat_widget
|
||||
.selected_index_for_present_view(agents_overview::AGENTS_OVERVIEW_VIEW_ID)
|
||||
.and_then(|index| self.agents_overview.visible_thread_ids.get(index).copied());
|
||||
let displayed = self.current_displayed_thread_id();
|
||||
let mut input = self.chat_widget.capture_thread_input_state();
|
||||
if let Some(input) = input.as_mut() {
|
||||
@@ -249,29 +282,10 @@ impl App {
|
||||
}
|
||||
if let Some(mut started) = thread {
|
||||
let id = started.session.thread_id;
|
||||
// A lost settings response can leave the server snapshot behind the user's choice.
|
||||
// Side conversations can replace the app-wide overrides, so only restore choices
|
||||
// that still match this conversation's own cached settings.
|
||||
if (self.current_displayed_thread_id() == Some(id)
|
||||
|| self.primary_thread_id == Some(id))
|
||||
&& let Some(channel) = self.thread_event_channels.get(&id)
|
||||
if let Some(channel) = self.thread_event_channels.get(&id)
|
||||
&& let Some(cached) = channel.store.lock().await.session.as_ref()
|
||||
{
|
||||
if let Some(policy) = self.runtime_approval_policy_override
|
||||
&& policy == cached.approval_policy
|
||||
{
|
||||
started.session.approval_policy = policy;
|
||||
}
|
||||
if let Some(profile) = &self.runtime_permission_profile_override
|
||||
&& profile.permission_profile == cached.permission_profile
|
||||
&& profile.active_permission_profile == cached.active_permission_profile
|
||||
&& self.config.approvals_reviewer == cached.approvals_reviewer
|
||||
{
|
||||
started.session.permission_profile = profile.permission_profile.clone();
|
||||
started.session.active_permission_profile =
|
||||
profile.active_permission_profile.clone();
|
||||
started.session.approvals_reviewer = self.config.approvals_reviewer;
|
||||
}
|
||||
self.restore_runtime_permissions(&mut started.session, cached);
|
||||
}
|
||||
self.agents_overview.input_states.remove(&id);
|
||||
if started
|
||||
@@ -329,6 +343,14 @@ impl App {
|
||||
self.replace_chat_widget(ChatWidget::new_with_app_event(init));
|
||||
self.chat_widget.restore_reconnected_input(input);
|
||||
}
|
||||
if self.reconnect.presentation == ReconnectPresentation::Overview {
|
||||
if let Ok(mut state) = self.agents_overview.view_state.lock() {
|
||||
state.connection_notice = None;
|
||||
}
|
||||
let view = self.agents_overview_view(self.agents_overview.threads.clone(), selected);
|
||||
self.chat_widget.show_bottom_pane_view(Box::new(view));
|
||||
self.refresh_agents_overview_threads(app_server);
|
||||
}
|
||||
// Only accept fresh task-tool calls once this connection and its event queue are adopted.
|
||||
if let ThreadToolTransport::Mcp(server) = app_server.thread_tool_transport() {
|
||||
server.reconnect(app_server.request_handle(), self.app_event_tx.clone());
|
||||
|
||||
@@ -367,7 +367,11 @@ impl App {
|
||||
app_server: &mut AppServerSession,
|
||||
thread_id: ThreadId,
|
||||
) -> Result<bool> {
|
||||
if self.thread_event_channels.contains_key(&thread_id) {
|
||||
if self
|
||||
.thread_event_channels
|
||||
.get(&thread_id)
|
||||
.is_some_and(|channel| channel.attachment() == ThreadEventAttachment::Live)
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
@@ -380,6 +384,14 @@ impl App {
|
||||
.await
|
||||
{
|
||||
Ok(started) => {
|
||||
if let Some(entry) = self.agent_navigation.get(&thread_id).cloned() {
|
||||
self.upsert_agent_picker_thread(
|
||||
thread_id,
|
||||
entry.agent_nickname,
|
||||
entry.agent_role,
|
||||
/*is_closed*/ false,
|
||||
);
|
||||
}
|
||||
if started.blocks_direct_input {
|
||||
self.agent_navigation.mark_parent_owned(thread_id);
|
||||
}
|
||||
@@ -417,18 +429,40 @@ impl App {
|
||||
));
|
||||
}
|
||||
let mut session = self.session_state_for_thread_read(thread_id, &thread).await;
|
||||
// Reads have no settings. Keep this cached thread's permissions rather than
|
||||
// inferring them from the conversation that is currently displayed.
|
||||
if let Some(channel) = self.thread_event_channels.get(&thread_id)
|
||||
&& let Some(cached) = channel.store.lock().await.session.as_ref()
|
||||
{
|
||||
session.approval_policy = cached.approval_policy;
|
||||
session.permission_profile = cached.permission_profile.clone();
|
||||
session.active_permission_profile = cached.active_permission_profile.clone();
|
||||
session.approvals_reviewer = cached.approvals_reviewer;
|
||||
}
|
||||
// `thread/read` can seed replay state, but it does not attach the app-server
|
||||
// listener that `thread/resume` establishes, so treat this path as replay-only.
|
||||
session.model.clear();
|
||||
(session, turns, false)
|
||||
}
|
||||
};
|
||||
let recap_progress =
|
||||
if live_attached && let Some(channel) = self.thread_event_channels.remove(&thread_id) {
|
||||
let store = channel.store.lock().await;
|
||||
if let Some(input) = store.input_state.clone() {
|
||||
self.agents_overview.input_states.insert(thread_id, input);
|
||||
}
|
||||
store.recap_progress()
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
let channel = self.ensure_thread_channel(thread_id);
|
||||
if !live_attached {
|
||||
channel.mark_replay_only();
|
||||
}
|
||||
let mut store = channel.store.lock().await;
|
||||
store.set_session(session, turns);
|
||||
store.merge_recap_progress(recap_progress);
|
||||
store.rebase_buffer_after_session_refresh();
|
||||
Ok(live_attached)
|
||||
}
|
||||
|
||||
@@ -471,8 +505,22 @@ impl App {
|
||||
app_server: &mut AppServerSession,
|
||||
thread_id: ThreadId,
|
||||
) -> Result<()> {
|
||||
let cached_session = if self.thread_unavailable(thread_id) {
|
||||
self.thread_event_channels[&thread_id]
|
||||
.store
|
||||
.lock()
|
||||
.await
|
||||
.session
|
||||
.clone()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if self.active_thread_id == Some(thread_id) {
|
||||
return Ok(());
|
||||
if !self.thread_unavailable(thread_id) {
|
||||
return Ok(());
|
||||
}
|
||||
// Detach the cached receiver before a successful attachment replaces its channel.
|
||||
self.store_active_thread_receiver().await;
|
||||
}
|
||||
|
||||
// A tracked side thread stays loaded until it is explicitly discarded and already has a
|
||||
@@ -491,7 +539,6 @@ impl App {
|
||||
.agent_navigation
|
||||
.get(&thread_id)
|
||||
.is_some_and(|entry| entry.is_closed);
|
||||
is_replay_only |= self.thread_unavailable(thread_id);
|
||||
let mut attached_replay_only = false;
|
||||
if self.should_attach_live_thread_for_selection(thread_id) {
|
||||
match self
|
||||
@@ -500,7 +547,11 @@ impl App {
|
||||
{
|
||||
Ok(live_attached) => {
|
||||
attached_replay_only = !live_attached;
|
||||
is_replay_only |= attached_replay_only;
|
||||
is_replay_only = attached_replay_only;
|
||||
}
|
||||
Err(_) if self.thread_event_channels.contains_key(&thread_id) => {
|
||||
is_replay_only = true;
|
||||
attached_replay_only = true;
|
||||
}
|
||||
Err(err) => {
|
||||
self.chat_widget.add_error_message(format!(
|
||||
@@ -541,10 +592,22 @@ impl App {
|
||||
.add_error_message(format!("Agent thread {thread_id} is no longer available."));
|
||||
return Ok(());
|
||||
};
|
||||
let recap_progress = channel.store.lock().await.recap_progress();
|
||||
if snapshot.input_state.is_none() {
|
||||
snapshot.input_state = self.agents_overview.input_states.remove(&thread_id);
|
||||
}
|
||||
let recap_progress = {
|
||||
let mut store = channel.store.lock().await;
|
||||
if let (Some(cached), Some(session)) =
|
||||
(cached_session.as_ref(), snapshot.session.as_mut())
|
||||
{
|
||||
self.restore_runtime_permissions(session, cached);
|
||||
store.session = Some(session.clone());
|
||||
if !is_replay_only && self.primary_thread_id == Some(thread_id) {
|
||||
self.primary_session_configured = Some(session.clone());
|
||||
}
|
||||
}
|
||||
store.recap_progress()
|
||||
};
|
||||
snapshot.input_state = snapshot
|
||||
.input_state
|
||||
.or(self.agents_overview.input_states.remove(&thread_id));
|
||||
|
||||
self.active_thread_id = Some(thread_id);
|
||||
self.active_thread_rx = Some(receiver);
|
||||
@@ -604,11 +667,13 @@ impl App {
|
||||
}
|
||||
|
||||
pub(super) fn should_attach_live_thread_for_selection(&self, thread_id: ThreadId) -> bool {
|
||||
!self.thread_event_channels.contains_key(&thread_id)
|
||||
self.thread_event_channels
|
||||
.get(&thread_id)
|
||||
.is_none_or(|channel| channel.attachment() != ThreadEventAttachment::Live)
|
||||
&& self
|
||||
.agent_navigation
|
||||
.get(&thread_id)
|
||||
.is_none_or(|entry| !entry.is_closed)
|
||||
.is_none_or(|entry| !entry.is_closed || self.thread_unavailable(thread_id))
|
||||
}
|
||||
|
||||
pub(super) fn reset_for_thread_switch(&mut self, tui: &mut tui::Tui) -> Result<()> {
|
||||
|
||||
@@ -716,11 +716,7 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
Ok(exit_reason)
|
||||
} else {
|
||||
loop {
|
||||
if app.reconnect.offline
|
||||
&& !app.reconnect.failed
|
||||
&& reconnect.is_none()
|
||||
&& app.reconnect.presentation == reconnect::ReconnectPresentation::Conversation
|
||||
{
|
||||
if app.reconnect.offline && !app.reconnect.failed && reconnect.is_none() {
|
||||
reconnect = Some(Box::pin(reconnect::reconnect(
|
||||
app.app_server_target.clone(),
|
||||
app.config.clone(),
|
||||
|
||||
@@ -8638,3 +8638,7 @@ async fn start_config_write_test_app_server(app: &App) -> Result<AppServerSessio
|
||||
|
||||
#[path = "tests/active_reconnect_tests.rs"]
|
||||
mod active_reconnect;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[path = "tests/navigation_reconnect_tests.rs"]
|
||||
mod navigation_reconnect;
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
//! Command-center inventory and cached navigation recover independently of active work.
|
||||
|
||||
use super::active_reconnect::drain_history;
|
||||
use super::disconnect::serve_reconnect_requests;
|
||||
use super::*;
|
||||
use crate::app::reconnect::ReconnectPresentation;
|
||||
use crate::app::reconnect::reconnect;
|
||||
use crate::app_server_session::ThreadParamsMode;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conversation()
|
||||
-> Result<()> {
|
||||
use super::super::agents_overview::AGENTS_OVERVIEW_VIEW_ID;
|
||||
use codex_app_server_protocol::Thread;
|
||||
use codex_app_server_protocol::ThreadStatus;
|
||||
use tokio::net::UnixListener;
|
||||
|
||||
// Losing an optional, previously opened thread must not strand the command center either.
|
||||
for (previous_thread, changed_child_permissions) in [
|
||||
(None, false),
|
||||
(Some(ThreadId::new()), false),
|
||||
(Some(ThreadId::new()), true),
|
||||
] {
|
||||
let (mut app, mut events, _) = make_test_app_with_channels().await;
|
||||
let child = ThreadId::new();
|
||||
let child_recap = recap::RecapProgress {
|
||||
completed_turns: 3,
|
||||
last_recapped_turn_count: Some(3),
|
||||
};
|
||||
app.config.model = Some("gpt-test".into());
|
||||
app.active_thread_id = previous_thread;
|
||||
app.primary_thread_id = previous_thread;
|
||||
if let Some(id) = previous_thread {
|
||||
app.config
|
||||
.permissions
|
||||
.set_permission_profile(PermissionProfile::read_only())?;
|
||||
app.runtime_approval_policy_override = Some(AskForApproval::OnRequest);
|
||||
app.runtime_permission_profile_override =
|
||||
Some(RuntimePermissionProfileOverride::from_config(&app.config));
|
||||
let mut session = test_thread_session(id, app.config.cwd.to_path_buf());
|
||||
session.approval_policy = AskForApproval::OnRequest;
|
||||
session.permission_profile = PermissionProfile::read_only();
|
||||
app.primary_session_configured = Some(session.clone());
|
||||
app.chat_widget.handle_thread_session(session.clone());
|
||||
app.ensure_thread_channel(id)
|
||||
.store
|
||||
.lock()
|
||||
.await
|
||||
.set_session(
|
||||
session,
|
||||
vec![test_turn(
|
||||
"cached",
|
||||
TurnStatus::Completed,
|
||||
vec![ThreadItem::Plan {
|
||||
id: "cached-plan".into(),
|
||||
text: "Cached previous conversation".into(),
|
||||
}],
|
||||
)],
|
||||
);
|
||||
app.chat_widget
|
||||
.restore_user_message_to_composer("old draft".into());
|
||||
app.store_active_thread_receiver().await;
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
|
||||
assert!(app.chat_widget.composer_is_empty());
|
||||
app.chat_widget
|
||||
.restore_user_message_to_composer("latest draft".into());
|
||||
}
|
||||
if previous_thread.is_some() {
|
||||
let child_session = test_thread_session(child, app.config.cwd.to_path_buf());
|
||||
app.ensure_thread_channel(child)
|
||||
.store
|
||||
.lock()
|
||||
.await
|
||||
.set_session(child_session, Vec::new());
|
||||
app.thread_event_channels[&child]
|
||||
.store
|
||||
.lock()
|
||||
.await
|
||||
.merge_recap_progress(child_recap);
|
||||
app.upsert_agent_picker_thread(
|
||||
child, /*agent_nickname*/ None, /*agent_role*/ None,
|
||||
/*is_closed*/ false,
|
||||
);
|
||||
}
|
||||
let selected = ThreadId::from_string("00000000-0000-0000-0000-000000000001")?;
|
||||
let vanished = ThreadId::new();
|
||||
let added = ThreadId::new();
|
||||
let make_thread = |id: ThreadId, name: &str, status: ThreadStatus| -> Result<Thread> {
|
||||
Ok(serde_json::from_value(json!({
|
||||
"id": id.to_string(), "sessionId": id.to_string(), "preview": name, "name": name,
|
||||
"ephemeral": false, "modelProvider": "test-provider", "createdAt": 1,
|
||||
"updatedAt": chrono::Utc::now().timestamp() - 120, "status": status,
|
||||
"cwd": app.config.cwd, "cliVersion": "0.0.0", "source": "cli", "turns": []
|
||||
}))?)
|
||||
};
|
||||
let child_thread = make_thread(child, "Cached child", ThreadStatus::NotLoaded)?;
|
||||
let stale = vec![
|
||||
make_thread(
|
||||
selected,
|
||||
"Selected task",
|
||||
ThreadStatus::Active {
|
||||
active_flags: Vec::new(),
|
||||
},
|
||||
)?,
|
||||
make_thread(vanished, "Vanished task", ThreadStatus::Idle)?,
|
||||
];
|
||||
let fresh = [
|
||||
make_thread(selected, "Selected task", ThreadStatus::Idle)?,
|
||||
make_thread(
|
||||
added,
|
||||
"New task",
|
||||
ThreadStatus::Active {
|
||||
active_flags: Vec::new(),
|
||||
},
|
||||
)?,
|
||||
];
|
||||
app.agents_overview.threads = stale.clone();
|
||||
let view = app.agents_overview_view(
|
||||
stale.clone(),
|
||||
Some(if previous_thread.is_some() {
|
||||
vanished
|
||||
} else {
|
||||
selected
|
||||
}),
|
||||
);
|
||||
app.agents_overview.visible_thread_ids = view.thread_ids();
|
||||
app.chat_widget.show_bottom_pane_view(Box::new(view));
|
||||
app.agents_overview.view_state.lock().unwrap().input = "Keep this task draft".into();
|
||||
app.agents_overview.view_state.lock().unwrap().renaming = previous_thread.is_some();
|
||||
let stale_request = Uuid::new_v4();
|
||||
app.agents_overview.request_id = Some(stale_request);
|
||||
app.agents_overview.refresh_pending = true;
|
||||
let refresh = tokio::spawn(std::future::pending::<()>());
|
||||
app.agents_overview.view_state.lock().unwrap().refresh_task = Some(refresh.abort_handle());
|
||||
let directory = tempfile::tempdir()?;
|
||||
let socket_path = directory.path().join("daemon.sock");
|
||||
let listener = UnixListener::bind(&socket_path)?;
|
||||
let endpoint = crate::RemoteAppServerEndpoint::UnixSocket {
|
||||
socket_path: codex_utils_absolute_path::AbsolutePathBuf::try_from(socket_path.clone())?,
|
||||
};
|
||||
app.app_server_target = AppServerTarget::LocalDaemon {
|
||||
endpoint: endpoint.clone(),
|
||||
};
|
||||
let available = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let server_available = Arc::clone(&available);
|
||||
let restored_previous = previous_thread
|
||||
.map(|id| make_thread(id, "Recovered task", ThreadStatus::NotLoaded))
|
||||
.transpose()?;
|
||||
let server = tokio::spawn(async move {
|
||||
let mut listener = listener;
|
||||
let mut methods = Vec::new();
|
||||
let mut child_opened = false;
|
||||
for connection in 0..(2 + usize::from(previous_thread.is_some())) {
|
||||
let (stream, _) = listener.accept().await?;
|
||||
let socket = tokio_tungstenite::accept_async(stream).await?;
|
||||
methods.extend(serve_reconnect_requests(socket, |request| std::future::ready({
|
||||
match request.method.as_str() {
|
||||
"thread/loaded/list" if connection == 0 => None,
|
||||
"thread/resume" if connection == 1 && previous_thread.is_some() => None,
|
||||
"thread/resume" if request.params.as_ref().unwrap()["threadId"] != child.to_string() && !server_available.load(std::sync::atomic::Ordering::SeqCst) => Some(json!({"error": {"code": -32600, "message": "thread no longer exists"}})),
|
||||
"thread/resume" => {
|
||||
let mut thread = if request.params.as_ref().unwrap()["threadId"] == child.to_string() {
|
||||
child_opened = true;
|
||||
child_thread.clone()
|
||||
} else {
|
||||
restored_previous.clone().unwrap()
|
||||
};
|
||||
if changed_child_permissions && thread.id != child.to_string() {
|
||||
thread.turns = vec![test_turn("saved", TurnStatus::InProgress, Vec::new())];
|
||||
}
|
||||
let (approval, sandbox) = if changed_child_permissions && thread.id != child.to_string() {
|
||||
("on-request", "readOnly")
|
||||
} else {
|
||||
("never", "dangerFullAccess")
|
||||
};
|
||||
Some(json!({"result": {"thread": thread, "model": "gpt-test", "modelProvider": "test-provider", "cwd": thread.cwd, "approvalPolicy": approval, "approvalsReviewer": "user", "sandbox": {"type": sandbox}, "reasoningEffort": null}}))
|
||||
}
|
||||
"turn/start" => {
|
||||
let params = request.params.as_ref().unwrap();
|
||||
assert_eq!(params["approvalPolicy"], "on-request");
|
||||
assert_eq!(params["sandboxPolicy"]["type"], if changed_child_permissions { json!(null) } else { json!("readOnly") });
|
||||
assert_eq!(params["input"][0]["text"], "latest draft!");
|
||||
Some(json!({"result": {"turn": {"id": "fresh", "items": [], "status": "inProgress"}}}))
|
||||
}
|
||||
"thread/loaded/list" => Some(json!({"result": {"data": [selected, added], "nextCursor": null}})),
|
||||
"thread/turns/list" => Some(json!({"result": {"data": [], "nextCursor": null}})),
|
||||
"thread/goal/get" => Some(json!({"result": {"goal": null}})),
|
||||
"thread/read" if request.params.as_ref().unwrap()["threadId"] == child.to_string() => Some(json!({"result": {"thread": child_thread}})),
|
||||
"thread/read" if previous_thread.is_some_and(|id| request.params.as_ref().unwrap()["threadId"] == id.to_string()) => Some(if changed_child_permissions && child_opened {
|
||||
let mut thread = restored_previous.clone().unwrap();
|
||||
let status = if server_available.load(std::sync::atomic::Ordering::SeqCst) { TurnStatus::InProgress } else { TurnStatus::Completed };
|
||||
thread.turns = vec![test_turn("saved", status, Vec::new())];
|
||||
json!({"result": {"thread": thread}})
|
||||
} else if server_available.load(std::sync::atomic::Ordering::SeqCst) { json!({"result": {"thread": restored_previous}}) } else { json!({"error": {"code": -32600, "message": "thread no longer exists"}}) }),
|
||||
"thread/read" => Some(json!({"result": {"thread": fresh.iter().find(|thread| request.params.as_ref().unwrap()["threadId"] == thread.id).unwrap()}})),
|
||||
method => panic!("unexpected daemon reconnect request: {method}"),
|
||||
}
|
||||
})).await?);
|
||||
if connection == 0 {
|
||||
drop(listener);
|
||||
std::fs::remove_file(&socket_path)?;
|
||||
listener = UnixListener::bind(&socket_path)?;
|
||||
}
|
||||
}
|
||||
Ok::<_, color_eyre::Report>(methods)
|
||||
});
|
||||
let mut session = AppServerSession::new(
|
||||
crate::connect_remote_app_server(endpoint).await?,
|
||||
ThreadParamsMode::Embedded,
|
||||
);
|
||||
assert!(
|
||||
session
|
||||
.thread_loaded_list(ThreadLoadedListParams {
|
||||
cursor: None,
|
||||
limit: Some(100)
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let disconnected = session.next_event().await.unwrap();
|
||||
app.handle_app_server_event(&session, disconnected).await;
|
||||
assert!(app.reconnect.offline);
|
||||
assert!(refresh.await.unwrap_err().is_cancelled());
|
||||
assert_eq!(
|
||||
(
|
||||
app.agents_overview.request_id,
|
||||
app.agents_overview.refresh_pending
|
||||
),
|
||||
(None, false)
|
||||
);
|
||||
app.apply_agents_overview_thread_refresh(&session, stale_request, Ok(Vec::new()));
|
||||
assert_eq!(app.agents_overview.visible_thread_ids.len(), 2);
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
app.handle_tui_event(
|
||||
&mut tui,
|
||||
&mut session,
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
|
||||
)
|
||||
.await?;
|
||||
app.handle_tui_event(&mut tui, &mut session, TuiEvent::Paste("!".into()))
|
||||
.await?;
|
||||
assert_eq!(
|
||||
app.agents_overview.view_state.lock().unwrap().input,
|
||||
"Keep this task draft!"
|
||||
);
|
||||
if previous_thread.is_none() {
|
||||
assert_snapshot!(
|
||||
"daemon_command_center_reconnecting",
|
||||
render_bottom_popup(&app.chat_widget, /*width*/ 100)
|
||||
);
|
||||
app.agents_overview
|
||||
.view_state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.connection_notice =
|
||||
Some("Reconnect failed — agent list is stale; relaunch to retry");
|
||||
assert_snapshot!(
|
||||
"daemon_command_center_failed",
|
||||
render_bottom_popup(&app.chat_widget, /*width*/ 100)
|
||||
);
|
||||
}
|
||||
let connected = reconnect(
|
||||
app.app_server_target.clone(),
|
||||
app.config.clone(),
|
||||
previous_thread,
|
||||
/*remote_cwd*/ None,
|
||||
session.thread_tool_transport(),
|
||||
ReconnectPresentation::Overview,
|
||||
)
|
||||
.await?;
|
||||
app.finish_reconnect(&mut tui, &mut session, &mut events, connected)
|
||||
.await?;
|
||||
assert!(!app.reconnect.offline);
|
||||
assert_eq!(app.current_displayed_thread_id(), previous_thread);
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(/*secs*/ 5), async {
|
||||
let mut deferred = Vec::new();
|
||||
while app.agents_overview.request_id.is_some() {
|
||||
let event = events.recv().await.expect("inventory refresh event");
|
||||
if matches!(event, AppEvent::AgentsOverviewThreadsLoaded { .. }) {
|
||||
app.handle_event(&mut tui, &mut session, event).await?;
|
||||
} else {
|
||||
deferred.push(event);
|
||||
}
|
||||
}
|
||||
for event in deferred {
|
||||
app.app_event_tx.send(event);
|
||||
}
|
||||
Ok::<_, color_eyre::Report>(())
|
||||
})
|
||||
.await??;
|
||||
let index = app
|
||||
.chat_widget
|
||||
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID)
|
||||
.unwrap();
|
||||
if previous_thread.is_none() {
|
||||
assert_eq!(app.agents_overview.visible_thread_ids[index], selected);
|
||||
}
|
||||
assert!(!app.agents_overview.visible_thread_ids.contains(&vanished));
|
||||
assert!(app.agents_overview.visible_thread_ids.contains(&added));
|
||||
assert_eq!(
|
||||
app.agents_overview.view_state.lock().unwrap().input,
|
||||
if previous_thread.is_some() {
|
||||
""
|
||||
} else {
|
||||
"Keep this task draft!"
|
||||
}
|
||||
);
|
||||
if previous_thread.is_some() {
|
||||
let history = drain_history(&mut app, &mut tui, &mut session, &mut events).await?;
|
||||
assert!(history.contains("Unsubmitted title: Keep this task draft!"));
|
||||
assert_snapshot!(
|
||||
"daemon_command_center_vanished_rename",
|
||||
render_bottom_popup(&app.chat_widget, /*width*/ 100)
|
||||
);
|
||||
app.handle_tui_event(
|
||||
&mut tui,
|
||||
&mut session,
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
|
||||
)
|
||||
.await?;
|
||||
assert!(
|
||||
!std::iter::from_fn(|| events.try_recv().ok())
|
||||
.any(|event| matches!(event, AppEvent::DispatchAgentsOverviewTask { .. }))
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
app.agents_overview
|
||||
.view_state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.connection_notice
|
||||
.is_none()
|
||||
);
|
||||
app.apply_agents_overview_thread_refresh(&session, stale_request, Ok(stale));
|
||||
assert!(!app.agents_overview.visible_thread_ids.contains(&vanished));
|
||||
|
||||
if let Some(id) = previous_thread {
|
||||
app.handle_tui_event(
|
||||
&mut tui,
|
||||
&mut session,
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert!(!app.chat_widget.has_active_view());
|
||||
assert_eq!(app.current_displayed_thread_id(), Some(id));
|
||||
let history = drain_history(&mut app, &mut tui, &mut session, &mut events).await?;
|
||||
assert!(history.contains("Cached previous conversation"));
|
||||
|
||||
let content = &history[history.find("Cached previous conversation").unwrap()..];
|
||||
assert_snapshot!(
|
||||
"reconnected_unavailable_conversation",
|
||||
format!(
|
||||
"{content}\n{}",
|
||||
render_bottom_popup(&app.chat_widget, /*width*/ 80)
|
||||
)
|
||||
);
|
||||
|
||||
app.handle_tui_event(
|
||||
&mut tui,
|
||||
&mut session,
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(app.chat_widget.composer_text_with_pending(), "latest draft");
|
||||
assert!(
|
||||
!std::iter::from_fn(|| events.try_recv().ok())
|
||||
.any(|event| matches!(event, AppEvent::CodexOp(AppCommand::UserTurn { .. })))
|
||||
);
|
||||
app.handle_tui_event(&mut tui, &mut session, TuiEvent::Paste("!".into()))
|
||||
.await?;
|
||||
assert_eq!(
|
||||
app.chat_widget.composer_text_with_pending(),
|
||||
"latest draft!"
|
||||
);
|
||||
app.handle_tui_event(
|
||||
&mut tui,
|
||||
&mut session,
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT)),
|
||||
)
|
||||
.await?;
|
||||
assert!(app.chat_widget.has_active_view());
|
||||
app.handle_tui_event(
|
||||
&mut tui,
|
||||
&mut session,
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
app.chat_widget.composer_text_with_pending(),
|
||||
"latest draft!"
|
||||
);
|
||||
available.store(
|
||||
!changed_child_permissions,
|
||||
std::sync::atomic::Ordering::SeqCst,
|
||||
);
|
||||
app.select_agent_thread(&mut tui, &mut session, child)
|
||||
.await?;
|
||||
assert_eq!(app.recap.progress(), child_recap);
|
||||
assert_eq!(app.primary_thread_id, Some(id));
|
||||
assert_eq!(
|
||||
app.chat_widget
|
||||
.config_ref()
|
||||
.permissions
|
||||
.permission_profile(),
|
||||
&PermissionProfile::Disabled
|
||||
);
|
||||
if changed_child_permissions {
|
||||
app.runtime_approval_policy_override = Some(AskForApproval::Never);
|
||||
app.runtime_permission_profile_override = Some(
|
||||
RuntimePermissionProfileOverride::from_config(app.chat_widget.config_ref()),
|
||||
);
|
||||
app.select_agent_thread(&mut tui, &mut session, id).await?;
|
||||
assert!(app.thread_unavailable(id));
|
||||
assert_eq!(app.current_displayed_thread_id(), Some(id));
|
||||
assert_eq!(
|
||||
app.chat_widget
|
||||
.config_ref()
|
||||
.permissions
|
||||
.permission_profile(),
|
||||
&PermissionProfile::read_only()
|
||||
);
|
||||
}
|
||||
available.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
app.handle_event(
|
||||
&mut tui,
|
||||
&mut session,
|
||||
AppEvent::SelectAgentsOverviewThread { thread_id: id },
|
||||
)
|
||||
.await?;
|
||||
assert!(!app.thread_unavailable(id));
|
||||
assert_eq!(
|
||||
app.primary_session_configured,
|
||||
app.thread_event_channels[&id].store.lock().await.session
|
||||
);
|
||||
assert!(!app.agent_navigation.get(&id).unwrap().is_closed);
|
||||
app.agent_navigation.mark_running(id);
|
||||
assert!(app.agent_navigation.get(&id).unwrap().is_running);
|
||||
assert_eq!(
|
||||
app.chat_widget.composer_text_with_pending(),
|
||||
"latest draft!"
|
||||
);
|
||||
if changed_child_permissions {
|
||||
assert!(app.chat_widget.is_task_running_for_test());
|
||||
// From an empty draft, daemon Ctrl+C offers the existing Cancel task action.
|
||||
for key in [
|
||||
KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL),
|
||||
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
||||
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
|
||||
] {
|
||||
app.handle_tui_event(&mut tui, &mut session, TuiEvent::Key(key))
|
||||
.await?;
|
||||
}
|
||||
assert!(std::iter::from_fn(|| events.try_recv().ok())
|
||||
.any(|event| matches!(event, AppEvent::RunningTaskExit { action: RunningTaskExitAction::CancelTask, thread_id } if thread_id == id)));
|
||||
app.handle_app_server_event(
|
||||
&session,
|
||||
codex_app_server_client::AppServerEvent::ServerNotification(Box::new(
|
||||
turn_completed_notification(id, "saved", TurnStatus::Completed),
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
app.drain_active_thread_events(&mut tui).await?;
|
||||
app.chat_widget
|
||||
.restore_user_message_to_composer("latest draft!".into());
|
||||
}
|
||||
app.handle_tui_event(
|
||||
&mut tui,
|
||||
&mut session,
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
|
||||
)
|
||||
.await?;
|
||||
while let Ok(event) = events.try_recv() {
|
||||
if matches!(event, AppEvent::CodexOp(AppCommand::UserTurn { .. })) {
|
||||
app.handle_event(&mut tui, &mut session, event).await?;
|
||||
}
|
||||
}
|
||||
let channel = app.thread_event_channels.get(&id).unwrap();
|
||||
let event = ThreadBufferedEvent::Notification(Box::new(
|
||||
agent_message_delta_notification(id, "new-turn", "new-item", "fresh delivery"),
|
||||
));
|
||||
channel.sender.send(event).await?;
|
||||
assert!(app.active_thread_rx.as_mut().unwrap().try_recv().is_ok());
|
||||
}
|
||||
session.shutdown().await?;
|
||||
let methods = server.await??;
|
||||
assert_eq!(
|
||||
methods
|
||||
.iter()
|
||||
.filter(|method| *method == "turn/start")
|
||||
.count(),
|
||||
usize::from(previous_thread.is_some())
|
||||
);
|
||||
assert_eq!(
|
||||
methods.iter().any(|method| method == "thread/resume"),
|
||||
previous_thread.is_some()
|
||||
);
|
||||
assert_eq!(
|
||||
methods
|
||||
.iter()
|
||||
.filter(|method| *method == "initialize")
|
||||
.count(),
|
||||
2 + usize::from(previous_thread.is_some())
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
+1
-1
@@ -3,7 +3,7 @@ source: tui/src/app/tests/disconnect_tests.rs
|
||||
expression: "render_bottom_popup(&app.chat_widget, 100)"
|
||||
---
|
||||
Agent command center
|
||||
Disconnected — agent list is stale
|
||||
Reconnecting — agent list is stale
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
│
|
||||
│
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
---
|
||||
source: tui/src/app/tests/navigation_reconnect_tests.rs
|
||||
expression: "render_bottom_popup(&app.chat_widget, 100)"
|
||||
---
|
||||
Agent command center
|
||||
Reconnect failed — agent list is stale; relaunch to retry
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
/tmp/project 2 │ Task details
|
||||
› ● Selected task Working │
|
||||
○ Vanished task Ready │ Selected task
|
||||
│ ● Working
|
||||
│
|
||||
│ Project
|
||||
│ /tmp/project
|
||||
│
|
||||
│ Latest activity
|
||||
│ Selected task
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
New task › Keep this task draft!
|
||||
ctrl+c quit · actions paused until the list is refreshed
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
---
|
||||
source: tui/src/app/tests/navigation_reconnect_tests.rs
|
||||
expression: "render_bottom_popup(&app.chat_widget, 100)"
|
||||
---
|
||||
Agent command center
|
||||
Reconnecting — agent list is stale
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
/tmp/project 2 │ Task details
|
||||
› ● Selected task Working │
|
||||
○ Vanished task Ready │ Selected task
|
||||
│ ● Working
|
||||
│
|
||||
│ Project
|
||||
│ /tmp/project
|
||||
│
|
||||
│ Latest activity
|
||||
│ Selected task
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
New task › Keep this task draft!
|
||||
ctrl+c quit · actions paused until the list is refreshed
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
---
|
||||
source: tui/src/app/tests/navigation_reconnect_tests.rs
|
||||
expression: "render_bottom_popup(&app.chat_widget, 100)"
|
||||
---
|
||||
Agent command center
|
||||
0 need input 1 working 1 ready
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
/tmp/project 2 │ Task details
|
||||
› ● New task Working │
|
||||
○ Selected task Ready │ New task
|
||||
│ ● Working
|
||||
│
|
||||
│ Project
|
||||
│ /tmp/project
|
||||
│
|
||||
│ Latest activity
|
||||
│ New task
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
│
|
||||
New task › Describe a task and press enter to dispatch it
|
||||
↑↓ navigate enter open ctrl+f search ctrl+s group ctrl+r rename ctrl+x stop esc back
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
---
|
||||
source: tui/src/app/tests/navigation_reconnect_tests.rs
|
||||
expression: "format!(\"{content}\\n{}\", render_bottom_popup(&app.chat_widget, 80))"
|
||||
---
|
||||
Cached previous conversation
|
||||
|
||||
• This conversation is unavailable. Its cached transcript and draft remain here; input is paused. Open the agent picker or return to the parent to continue.
|
||||
• Reconnected. No input was resent. Review uncertain submissions before retrying; recovered queues remain paused.
|
||||
› latest draft
|
||||
|
||||
gpt-test default · /tmp/project
|
||||
Reference in New Issue
Block a user