Show recent sessions in the agent command center (#42104)

## Why

The agent command center only listed sessions currently loaded by the app server, so completed or otherwise unloaded tasks could not be revisited from the overview.

## What changed

- Seed the overview with up to 20 recent root sessions in addition to loaded sessions, retain discovered sessions for the lifetime of the TUI, and update rows from thread lifecycle notifications.
- Allow unloaded sessions to be resumed from the overview using the normal working-directory selection and configuration rebuild flow. Explicit runtime permission choices carry across a cold resume, while settings inherited from the previously selected task do not.
- Fall back from `recency_at` to `updated_at` when connected to an older server, and continue showing loaded sessions when recent-history listing is unavailable.

## Testing

Added coverage for recent-session discovery and retention, notification races, legacy and failed listing fallbacks, cold-resume working-directory and permission handling, reconnect behavior, and unloaded-session rendering and selection.

GitOrigin-RevId: 668068b1594eb24b2ea7faccd0327804b8cd22ef
This commit is contained in:
Eric Traut
2026-09-01 17:28:35 +00:00
committed by copyberry
parent 9969043b95
commit f40e08478c
21 changed files with 1352 additions and 316 deletions
+27 -1
View File
@@ -206,6 +206,7 @@ mod agent_navigation;
mod agent_picker;
mod agent_status_feed;
mod agents_overview;
mod agents_overview_threads;
mod agents_overview_view;
pub(crate) use agents_overview::AGENTS_OVERVIEW_VIEW_ID;
mod app_server_event_targets;
@@ -232,6 +233,7 @@ mod recap;
mod reconnect;
mod replay_filter;
mod resize_reflow;
mod resume_config;
mod safety_buffering;
mod session_lifecycle;
mod side;
@@ -544,7 +546,7 @@ pub(crate) struct App {
harness_overrides: ConfigOverrides,
loader_overrides: LoaderOverrides,
cloud_config_bundle: CloudConfigBundleLoader,
runtime_approval_policy_override: Option<AskForApproval>,
runtime_approval_policy_override: Option<RuntimeApprovalPolicyOverride>,
runtime_permission_profile_override: Option<RuntimePermissionProfileOverride>,
pub(crate) file_search: FileSearchManager,
@@ -642,9 +644,31 @@ struct RuntimePermissionProfileOverride {
permission_profile: PermissionProfile,
active_permission_profile: Option<ActivePermissionProfile>,
network: Option<crate::legacy_core::config::NetworkProxySpec>,
approvals_reviewer: ApprovalsReviewer,
turn_override: RuntimePermissionProfileTurnOverride,
}
/// Separates user choices from settings inherited when attaching to another task.
#[derive(Debug, Clone, Copy, PartialEq)]
enum RuntimeApprovalPolicyOverride {
Explicit(AskForApproval),
Restored(AskForApproval),
}
impl RuntimeApprovalPolicyOverride {
fn policy(self) -> AskForApproval {
match self {
Self::Explicit(policy) | Self::Restored(policy) => policy,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum RuntimePolicyOverrideScope {
All,
ExplicitOnly,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RuntimePermissionProfileTurnOverride {
Preserve,
@@ -657,6 +681,7 @@ impl RuntimePermissionProfileOverride {
permission_profile: config.permissions.permission_profile().clone(),
active_permission_profile: config.permissions.active_permission_profile(),
network: config.permissions.network.clone(),
approvals_reviewer: config.approvals_reviewer,
turn_override: RuntimePermissionProfileTurnOverride::LegacySandbox,
}
}
@@ -672,6 +697,7 @@ impl RuntimePermissionProfileOverride {
self.permission_profile == *config.permissions.permission_profile()
&& self.active_permission_profile == config.permissions.active_permission_profile()
&& self.network == config.permissions.network
&& self.approvals_reviewer == config.approvals_reviewer
}
fn turn_permission_profile(&self) -> Option<&PermissionProfile> {
+177 -175
View File
@@ -1,9 +1,10 @@
//! Daemon-wide overview of loaded root sessions and their subagents.
//! Daemon-wide overview of recent and locally retained sessions and their subagents.
use super::agents_overview_view::AgentsOverviewGroup;
use super::agents_overview_view::AgentsOverviewRow;
use super::agents_overview_view::AgentsOverviewView;
use super::*;
use crate::app_event::AgentsOverviewThreadRefresh;
use crate::bottom_pane::SelectionDescriptionLayout;
use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionViewParams;
@@ -13,14 +14,6 @@ use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SessionSource;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadHistoryMode;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadLoadedListParams;
use codex_app_server_protocol::ThreadLoadedListResponse;
use codex_app_server_protocol::ThreadReadParams;
use codex_app_server_protocol::ThreadReadResponse;
use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::ThreadTurnsListParams;
use codex_app_server_protocol::ThreadTurnsListResponse;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput;
@@ -30,17 +23,30 @@ pub(crate) const AGENTS_OVERVIEW_VIEW_ID: &str = "agents-overview";
#[derive(Default)]
pub(super) struct AgentsOverviewState {
/// Missing metadata records a local resume until the next metadata refresh.
pub(super) threads: HashMap<ThreadId, Option<Thread>>,
pub(super) initialized: bool,
pub(super) request_id: Option<Uuid>,
pub(super) refresh_pending: bool,
pub(super) refresh_thread_ids: HashSet<ThreadId>,
pub(super) refresh_task: Option<tokio::task::AbortHandle>,
pub(super) refresh_notifications: HashMap<ThreadId, Vec<ServerNotification>>,
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>,
pub(super) dispatched_requests: HashMap<ThreadId, Vec<ServerRequest>>,
}
impl Drop for AgentsOverviewState {
fn drop(&mut self) {
if let Some(task) = self.refresh_task.take() {
task.abort();
}
}
}
impl App {
pub(super) fn open_agents_overview(&mut self, app_server: &AppServerSession) {
if matches!(self.app_server_target, AppServerTarget::Embedded) {
@@ -92,140 +98,76 @@ impl App {
return;
}
self.agents_overview.request_id = None;
self.agents_overview.refresh_pending = false;
let view = self.agents_overview_view(
self.agents_overview.threads.clone(),
/*selected_thread_id*/ None,
);
let threads = self
.agents_overview
.threads
.values()
.flatten()
.cloned()
.collect();
let view = self.agents_overview_view(threads, /*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);
}
pub(super) fn refresh_agents_overview_threads(&mut self, app_server: &AppServerSession) {
if self
.chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID)
.is_none()
{
return;
}
if self.agents_overview.request_id.is_some() {
self.agents_overview.refresh_pending = true;
return;
}
let request_id = Uuid::new_v4();
self.agents_overview.request_id = Some(request_id);
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
let refresh_task = tokio::spawn(async move {
let result = async {
let mut threads = Vec::new();
let mut cursor = None;
while threads.len() < 1_000 {
let page = request_handle
.request_typed::<ThreadLoadedListResponse>(ClientRequest::ThreadLoadedList {
request_id: RequestId::String(Uuid::new_v4().to_string()),
params: ThreadLoadedListParams {
cursor,
limit: Some(100),
},
})
.await
.map_err(|error| error.to_string())?;
let mut loaded_threads = tokio::task::JoinSet::new();
for thread_id in page.data.into_iter().take(1_000 - threads.len()) {
let request_handle = request_handle.clone();
loaded_threads.spawn(async move {
match request_handle
.request_typed::<ThreadReadResponse>(ClientRequest::ThreadRead {
request_id: RequestId::String(Uuid::new_v4().to_string()),
params: ThreadReadParams {
thread_id: thread_id.clone(),
include_turns: false,
},
})
.await
{
Ok(mut response) => {
if let Ok(turns) = request_handle
.request_typed::<ThreadTurnsListResponse>(
ClientRequest::ThreadTurnsList {
request_id: RequestId::String(
Uuid::new_v4().to_string(),
),
params: ThreadTurnsListParams {
thread_id,
cursor: None,
limit: Some(1),
sort_direction: None,
items_view: None,
},
},
)
.await
&& let Some(ThreadItem::UserMessage { content, .. }) = turns
.data
.first()
.and_then(|turn| turn.items.first())
{
response.thread.preview =
ChatWidget::user_message_display_from_inputs(content)
.message;
}
Some(response.thread)
}
Err(error) => {
tracing::warn!(thread_id, %error, "failed to read loaded agent thread");
None
}
}
});
if loaded_threads.len() >= 16
&& let Some(Ok(Some(thread))) = loaded_threads.join_next().await
{
threads.push(thread);
}
}
while let Some(result) = loaded_threads.join_next().await {
if let Ok(Some(thread)) = result {
threads.push(thread);
}
}
let Some(next_cursor) = page.next_cursor else {
break;
};
cursor = Some(next_cursor);
}
threads.sort_by_key(|thread| std::cmp::Reverse(thread.updated_at));
Ok(threads)
}
.await;
app_event_tx.send(AppEvent::AgentsOverviewThreadsLoaded { request_id, result });
});
if let Ok(mut state) = self.agents_overview.view_state.lock() {
state.refresh_task = Some(refresh_task.abort_handle());
}
}
pub(super) fn apply_agents_overview_thread_refresh(
&mut self,
app_server: &AppServerSession,
request_id: Uuid,
result: Result<Vec<Thread>, String>,
result: Result<AgentsOverviewThreadRefresh, String>,
) {
if self.agents_overview.request_id != Some(request_id) {
return;
}
self.agents_overview.request_id = None;
self.agents_overview.refresh_task = None;
match result {
Ok(refresh) => {
self.agents_overview.initialized = refresh.recent_seed_complete;
for (thread_id, thread) in refresh.threads {
if let Some(mut thread) = thread {
if thread.ephemeral {
self.agents_overview.threads.remove(&thread_id);
continue;
}
thread.turns.clear();
self.agents_overview.threads.insert(thread_id, Some(thread));
} else {
self.agents_overview.threads.entry(thread_id).or_default();
}
}
}
Err(error) => {
tracing::warn!(%error, "failed to refresh shared agents");
if self
.chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID)
.is_some()
{
self.chat_widget
.add_error_message(format!("Failed to load shared agents: {error}"));
}
}
}
for notifications in
std::mem::take(&mut self.agents_overview.refresh_notifications).into_values()
{
for notification in notifications {
self.track_agents_overview_notification(&notification);
}
}
if std::mem::take(&mut self.agents_overview.refresh_pending) {
self.refresh_changed_agents_overview_threads(app_server);
}
self.repaint_agents_overview();
}
pub(super) fn repaint_agents_overview(&mut self) {
let Some(selected) = self
.chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID)
else {
self.agents_overview.refresh_pending = false;
return;
};
let selected_thread_id = self
@@ -233,18 +175,13 @@ impl App {
.visible_thread_ids
.get(selected)
.copied();
let threads = match result {
Ok(threads) => threads,
Err(error) => {
self.chat_widget
.add_error_message(format!("Failed to load shared agents: {error}"));
if std::mem::take(&mut self.agents_overview.refresh_pending) {
self.refresh_agents_overview_threads(app_server);
}
return;
}
};
self.agents_overview.threads = threads.clone();
let threads = self
.agents_overview
.threads
.values()
.flatten()
.cloned()
.collect();
let view = self.agents_overview_view(threads, selected_thread_id);
self.agents_overview.visible_thread_ids = view.thread_ids();
if selected_thread_id
@@ -264,9 +201,6 @@ impl App {
}
self.chat_widget
.replace_bottom_pane_view_if_present(AGENTS_OVERVIEW_VIEW_ID, Box::new(view));
if std::mem::take(&mut self.agents_overview.refresh_pending) {
self.refresh_agents_overview_threads(app_server);
}
}
pub(super) fn agents_overview_view(
@@ -296,13 +230,15 @@ impl App {
let mut roots = threads
.iter()
.filter(|thread| {
thread.parent_thread_id.is_none()
&& !matches!(thread.status, ThreadStatus::NotLoaded)
})
.filter(|thread| thread.parent_thread_id.is_none())
.map(|root| (root, agents_overview_group(root, &children)))
.collect::<Vec<_>>();
roots.sort_by_key(|(root, group)| (*group, std::cmp::Reverse(root.updated_at)));
roots.sort_by(|(left, left_group), (right, right_group)| {
left_group
.cmp(right_group)
.then_with(|| right.updated_at.cmp(&left.updated_at))
.then_with(|| left.id.cmp(&right.id))
});
let mut rows = Vec::new();
for (root, group) in roots {
let Ok(thread_id) = ThreadId::from_string(&root.id) else {
@@ -396,32 +332,79 @@ impl App {
return Ok(AppRunControl::Continue);
}
};
let config_cwd = if self.app_server_target.uses_remote_workspace() {
self.config.cwd.to_path_buf()
let unloaded = matches!(
target_thread.status,
codex_app_server_protocol::ThreadStatus::NotLoaded
);
let mut resume_config = if unloaded {
let target_session = SessionTarget {
path: target_thread.path.clone(),
thread_id: root_thread_id,
history_mode: Some(target_thread.history_mode),
};
match self
.resume_config_for_target(tui, app_server, &target_session)
.await
{
Ok(config) => config,
Err(control) => return Ok(control),
}
} else {
target_thread.cwd.to_path_buf()
};
let current_cwd = self.config.cwd.to_path_buf();
let resume_config = match self
.rebuild_config_for_resume_or_fallback(&current_cwd, config_cwd)
.await
{
Ok(config) => config,
Err(error) => {
self.chat_widget
.add_error_message(format!("Failed to load task settings: {error}"));
return Ok(AppRunControl::Continue);
let config_cwd = if self.app_server_target.uses_remote_workspace() {
self.config.cwd.to_path_buf()
} else {
target_thread.cwd.to_path_buf()
};
let current_cwd = self.config.cwd.to_path_buf();
match self
.rebuild_config_for_resume_or_fallback(&current_cwd, config_cwd)
.await
{
Ok(config) => config,
Err(error) => {
self.chat_widget
.add_error_message(format!("Failed to load task settings: {error}"));
return Ok(AppRunControl::Continue);
}
}
};
let baseline_approval = resume_config.permissions.approval_policy.value();
let baseline_permissions =
RuntimePermissionProfileOverride::from_config(&resume_config);
let resume_model_settings = match target_thread.status {
codex_app_server_protocol::ThreadStatus::NotLoaded => {
self.apply_runtime_policy_overrides(
&mut resume_config,
RuntimePolicyOverrideScope::ExplicitOnly,
);
if matches!(self.runtime_approval_policy_override,
Some(RuntimeApprovalPolicyOverride::Explicit(policy))
if policy.to_core() != resume_config.permissions.approval_policy.value())
|| self
.runtime_permission_profile_override
.as_ref()
.is_some_and(|profile| {
profile.turn_override
== RuntimePermissionProfileTurnOverride::LegacySandbox
&& !profile.matches_config(&resume_config)
})
{
self.chat_widget.add_error_message(
"Cannot resume task without preserving the selected permissions."
.to_string(),
);
return Ok(AppRunControl::Continue);
}
self.resume_model_settings()
}
codex_app_server_protocol::ThreadStatus::Idle
| codex_app_server_protocol::ThreadStatus::Active { .. }
| codex_app_server_protocol::ThreadStatus::SystemError => {
crate::app_server_session::ResumeModelSettings::PreserveExistingThread
}
};
let resumed = match app_server
.resume_thread(
resume_config.clone(),
root_thread_id,
crate::app_server_session::ResumeModelSettings::PreserveExistingThread,
)
.resume_thread(resume_config.clone(), root_thread_id, resume_model_settings)
.await
{
Ok(resumed) => resumed,
@@ -459,8 +442,19 @@ impl App {
if previous_running_thread_ids.is_empty() {
self.shutdown_current_thread(app_server).await;
}
self.runtime_approval_policy_override = None;
self.runtime_permission_profile_override = None;
// Explicit user choices carry across cold resumes; inherited task settings do not.
self.runtime_approval_policy_override =
self.runtime_approval_policy_override.filter(|policy| {
unloaded && matches!(policy, RuntimeApprovalPolicyOverride::Explicit(_))
});
self.runtime_permission_profile_override = self
.runtime_permission_profile_override
.take()
.filter(|profile| {
unloaded
&& profile.turn_override
== RuntimePermissionProfileTurnOverride::LegacySandbox
});
self.config = resume_config;
tui.set_notification_settings(
self.config.tui_notifications.method,
@@ -492,14 +486,22 @@ impl App {
.set_workspace_roots(self.config.permissions.workspace_roots().to_vec());
}
self.config = destination_config;
if self.config.permissions.approval_policy.value() != baseline_approval {
self.runtime_approval_policy_override =
Some(self.config.permissions.approval_policy.value().into());
let approval = self.config.permissions.approval_policy.value();
if self
.runtime_approval_policy_override
.is_none_or(|policy| policy.policy().to_core() != approval)
{
self.runtime_approval_policy_override = (approval != baseline_approval)
.then_some(RuntimeApprovalPolicyOverride::Restored(approval.into()));
}
if !baseline_permissions.matches_config(&self.config) {
self.runtime_permission_profile_override = Some(
RuntimePermissionProfileOverride::from_restored_config(&self.config),
);
if self
.runtime_permission_profile_override
.as_ref()
.is_none_or(|profile| !profile.matches_config(&self.config))
{
self.runtime_permission_profile_override = (!baseline_permissions
.matches_config(&self.config))
.then(|| RuntimePermissionProfileOverride::from_restored_config(&self.config));
}
if !self
.backfill_loaded_subagent_threads(app_server)
@@ -614,7 +616,7 @@ impl App {
.chat_widget
.add_error_message("Permission profile has different settings.".to_string());
}
self.apply_runtime_policy_overrides(&mut config);
self.apply_runtime_policy_overrides(&mut config, RuntimePolicyOverrideScope::All);
apply_managed_new_thread_defaults(
&mut config,
app_server.managed_new_thread_defaults(),
+465 -4
View File
@@ -1,5 +1,6 @@
use super::*;
use crate::app::test_support::make_test_app;
use crate::app_event::AgentsOverviewThreadRefresh;
use crate::bottom_pane::BottomPaneView;
use crate::chatwidget::tests::helpers::render_bottom_popup;
use crate::render::renderable::Renderable;
@@ -11,8 +12,13 @@ use codex_app_server_protocol::CurrentTimeReadParams;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::SessionSource;
use codex_app_server_protocol::ThreadActiveFlag;
use codex_app_server_protocol::ThreadArchivedNotification;
use codex_app_server_protocol::ThreadClosedNotification;
use codex_app_server_protocol::ThreadDeletedNotification;
use codex_app_server_protocol::ThreadNameUpdatedNotification;
use codex_app_server_protocol::ThreadSource;
use codex_app_server_protocol::ThreadStartedNotification;
use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::ThreadUnsubscribeParams;
use codex_app_server_protocol::ThreadUnsubscribeResponse;
use codex_app_server_protocol::ThreadUnsubscribeStatus;
@@ -21,6 +27,7 @@ use codex_config::types::KeybindingsSpec;
use codex_config::types::TuiKeymap;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::SubAgentSource;
use pretty_assertions::assert_eq;
static OVERVIEW_TIMESTAMP: std::sync::LazyLock<i64> =
std::sync::LazyLock::new(|| chrono::Utc::now().timestamp() - 120);
@@ -70,6 +77,247 @@ fn overview_thread(
}
}
#[tokio::test]
async fn shared_overview_keeps_rows_and_replays_changes_over_stale_reads() -> Result<()> {
let mut app = make_test_app().await;
let app_server = crate::start_embedded_app_server_for_picker(&app.config).await?;
let [retained, archived, deleted, created] = std::array::from_fn(|_| ThreadId::new());
let [
mut retained_thread,
archived_thread,
deleted_thread,
created_thread,
] = [
(retained, "Old name"),
(archived, "Archived"),
(deleted, "Deleted"),
(created, "Created in another client"),
]
.map(|(id, name)| {
overview_thread(id, /*parent_thread_id*/ None, name, ThreadStatus::Idle)
});
let request_id = Uuid::new_v4();
app.agents_overview.request_id = Some(request_id);
// The seed is still in flight and the command center has never been opened.
for notification in [
ServerNotification::ThreadStarted(ThreadStartedNotification {
thread: created_thread.clone(),
}),
ServerNotification::ThreadNameUpdated(ThreadNameUpdatedNotification {
thread_id: retained.to_string(),
thread_name: Some("New name".to_string()),
}),
ServerNotification::ThreadClosed(ThreadClosedNotification {
thread_id: retained.to_string(),
}),
ServerNotification::ThreadArchived(ThreadArchivedNotification {
thread_id: archived.to_string(),
}),
ServerNotification::ThreadDeleted(ThreadDeletedNotification {
thread_id: deleted.to_string(),
}),
] {
app.handle_app_server_event(
&app_server,
AppServerEvent::ServerNotification(Box::new(notification)),
)
.await;
}
app.apply_agents_overview_thread_refresh(
&app_server,
request_id,
Ok(AgentsOverviewThreadRefresh {
threads: HashMap::from([
(retained, Some(retained_thread.clone())),
(archived, Some(archived_thread)),
(deleted, Some(deleted_thread)),
]),
recent_seed_complete: true,
}),
);
retained_thread.name = Some("New name".to_string());
retained_thread.status = ThreadStatus::NotLoaded;
let expected = HashMap::from([
(retained, Some(retained_thread)),
(created, Some(created_thread)),
]);
assert_eq!(app.agents_overview.threads, expected);
let request_id = Uuid::new_v4();
app.agents_overview.request_id = Some(request_id);
app.apply_agents_overview_thread_refresh(
&app_server,
request_id,
Ok(AgentsOverviewThreadRefresh {
threads: HashMap::from([(retained, None)]),
recent_seed_complete: true,
}),
);
assert_eq!(app.agents_overview.threads, expected);
let request_id = Uuid::new_v4();
app.agents_overview.request_id = Some(request_id);
app.apply_agents_overview_thread_refresh(
&app_server,
request_id,
Err("temporarily unavailable".to_string()),
);
assert_eq!(app.agents_overview.threads, expected);
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn shared_overview_seeds_once_and_retains_locally_resumed_history() -> Result<()> {
let mut app = make_test_app().await;
let mut ids = Vec::new();
for day in 1..=22 {
let source = match day {
3 => codex_protocol::protocol::SessionSource::Custom("atlas".to_string()),
4 => codex_protocol::protocol::SessionSource::Custom("chatgpt".to_string()),
5 => codex_protocol::protocol::SessionSource::Exec,
6 => codex_protocol::protocol::SessionSource::Mcp,
_ => codex_protocol::protocol::SessionSource::Cli,
};
ids.push(ThreadId::from_string(
&app_test_support::create_fake_rollout_with_source(
&app.config.codex_home,
&format!("2025-01-{day:02}T12-00-00"),
&format!("2025-01-{day:02}T12:00:00Z"),
&format!("Task {day}"),
Some(if day == 10 {
"other-provider"
} else {
&app.config.model_provider_id
}),
/*git_info*/ None,
source,
)
.expect("materialize historical session"),
)?);
}
let config = app.config.clone();
let mut app_server = crate::start_embedded_app_server_for_picker(&config).await?;
for thread_id in [ids[0], ids[21]] {
app_server
.resume_thread(
config.clone(),
thread_id,
crate::app_server_session::ResumeModelSettings::PreserveExistingThread,
)
.await?;
}
app.app_server_target = AppServerTarget::LocalDaemon {
endpoint: crate::RemoteAppServerEndpoint::UnixSocket {
socket_path: test_path_buf("/tmp/unused.sock").abs(),
},
};
let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel();
app.app_event_tx = crate::app_event_sender::AppEventSender::new(event_tx);
app.refresh_agents_overview_threads(&app_server);
finish_overview_refresh(&mut app, &app_server, &mut event_rx).await;
let recent_ids: HashSet<_> = ids[2..].iter().copied().collect();
let mut expected = recent_ids.clone();
expected.insert(ids[0]);
let retained: HashSet<_> = app.agents_overview.threads.keys().copied().collect();
assert_eq!(retained, expected);
let created = app_server.start_thread(&config).await?.session.thread_id;
// Closing the view must not cancel a metadata refresh or forget unloaded entries.
app.primary_thread_id = Some(ids[0]);
app.open_agents_overview(&app_server);
let visible: HashSet<_> = app
.agents_overview
.visible_thread_ids
.iter()
.copied()
.collect();
assert_eq!(visible, expected);
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
finish_overview_refresh(&mut app, &app_server, &mut event_rx).await;
let retained: HashSet<_> = app.agents_overview.threads.keys().copied().collect();
assert_eq!(retained, expected);
// A creation notification adds a session without attaching a local view.
tokio::time::timeout(Duration::from_secs(10), async {
loop {
let event = app_server.next_event().await.expect("server notification");
let is_created = matches!(&event, AppServerEvent::ServerNotification(notification)
if matches!(notification.as_ref(), ServerNotification::ThreadStarted(started) if started.thread.id == created.to_string()));
app.handle_app_server_event(&app_server, event).await;
if is_created { break; }
}
}).await.expect("creation notification received");
expected.insert(created);
let retained: HashSet<_> = app.agents_overview.threads.keys().copied().collect();
assert_eq!(retained, expected);
// Opening older history is a local addition, even without starting a turn.
let resumed = app_server
.resume_thread(
config.clone(),
ids[1],
crate::app_server_session::ResumeModelSettings::PreserveExistingThread,
)
.await?;
app.enqueue_primary_thread_session(resumed.session, resumed.turns)
.await?;
app.open_agents_overview(&app_server);
finish_overview_refresh(&mut app, &app_server, &mut event_rx).await;
expected.insert(ids[1]);
let visible: HashSet<_> = app
.agents_overview
.visible_thread_ids
.iter()
.copied()
.collect();
assert_eq!(visible, expected);
// An unloaded row can be selected through the same flow as a loaded row.
std::fs::write(
app.config.codex_home.join("config.toml"),
"[tui]\nresume_cwd = \"session\"\n",
)?;
let mut tui = crate::tui::test_support::make_test_tui()?;
app.select_agents_overview_thread(&mut tui, &mut app_server, ids[2])
.await?;
assert_eq!(app.primary_thread_id, Some(ids[2]));
app_server.shutdown().await?;
// A fresh TUI/server has no in-memory additions; read-only resumes did not promote history.
let mut restarted = make_test_app().await;
restarted.app_server_target = app.app_server_target.clone();
let app_server = crate::start_embedded_app_server_for_picker(&config).await?;
let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel();
restarted.app_event_tx = crate::app_event_sender::AppEventSender::new(event_tx);
restarted.refresh_agents_overview_threads(&app_server);
finish_overview_refresh(&mut restarted, &app_server, &mut event_rx).await;
let retained: HashSet<_> = restarted.agents_overview.threads.keys().copied().collect();
assert_eq!(retained, recent_ids);
app_server.shutdown().await?;
Ok(())
}
async fn finish_overview_refresh(
app: &mut App,
app_server: &AppServerSession,
events: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
) {
tokio::time::timeout(Duration::from_secs(10), async {
loop {
if let AppEvent::AgentsOverviewThreadsLoaded { request_id, result } =
events.recv().await.expect("overview event")
{
assert!(result.is_ok(), "{result:?}");
app.apply_agents_overview_thread_refresh(app_server, request_id, result);
return;
}
}
})
.await
.expect("overview refresh completed");
}
#[tokio::test]
async fn hidden_system_thread_does_not_refresh_shared_overview() {
let mut app = make_test_app().await;
@@ -164,6 +412,7 @@ async fn shared_overview_shows_only_root_sessions() {
);
let mut app = make_test_app().await;
let first_root = ThreadId::from_string("00000000-0000-0000-0000-000000000101").unwrap();
let unloaded_root = ThreadId::from_string("00000000-0000-0000-0000-000000000102").unwrap();
let [child, second_root, side_thread] = std::array::from_fn(|_| ThreadId::new());
app.primary_thread_id = Some(first_root);
@@ -190,6 +439,12 @@ async fn shared_overview_shows_only_root_sessions() {
active_flags: Vec::new(),
},
),
overview_thread(
unloaded_root,
/*parent_thread_id*/ None,
"Review yesterday's changes",
ThreadStatus::NotLoaded,
),
];
let mut side = overview_thread(
side_thread,
@@ -271,14 +526,14 @@ async fn shared_overview_shows_only_root_sessions() {
app.chat_widget.show_bottom_pane_view(Box::new(view));
let project = test_path_display("/tmp/project");
let normalized_project_group = format!(
"/tmp/project 2{}",
"/tmp/project 3{}",
" ".repeat(project.len().saturating_sub("/tmp/project".len()))
);
insta::with_settings!({snapshot_path => "../snapshots"}, {
insta::assert_snapshot!(
"agents_overview",
render_bottom_popup(&app.chat_widget, /*width*/ 96)
.replace(&format!("{project} 2"), &normalized_project_group)
.replace(&format!("{project} 3"), &normalized_project_group)
.replace(&project, "/tmp/project")
);
});
@@ -395,8 +650,7 @@ async fn filtered_dashboard_actions_use_configured_shortcuts() {
],
Some(first),
)
.rows
.clone(),
.rows,
Some(first),
/*exit_on_cancel*/ false,
crate::app_event_sender::AppEventSender::new(event_tx),
@@ -513,6 +767,213 @@ async fn root_switch_preserves_idle_root_with_running_subagent() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn overview_selection_applies_user_permissions_only_to_unloaded_threads() -> Result<()> {
let mut app = make_test_app().await;
std::fs::write(
app.config.codex_home.join("config.toml"),
"[tui]\nresume_cwd = \"session\"\n",
)?;
let mut server_config = app.config.clone();
server_config
.permissions
.set_permission_profile(PermissionProfile::workspace_write())?;
server_config
.permissions
.approval_policy
.set(codex_protocol::protocol::AskForApproval::Never)?;
let mut thread_ids = Vec::new();
for day in 1..=5 {
thread_ids.push(ThreadId::from_string(
&app_test_support::create_fake_rollout(
&server_config.codex_home,
&format!("2025-01-{day:02}T12-00-00"),
&format!("2025-01-{day:02}T12:00:00Z"),
"Historical session",
Some(&server_config.model_provider_id),
/*git_info*/ None,
)
.expect("create historical session"),
)?);
}
let mut app_server = crate::start_embedded_app_server_for_picker(&server_config).await?;
let loaded = app_server
.resume_thread(
server_config.clone(),
thread_ids[0],
crate::app_server_session::ResumeModelSettings::RestoreFromThread,
)
.await?
.session;
app.harness_overrides.sandbox_mode = Some(codex_protocol::config_types::SandboxMode::ReadOnly);
app.harness_overrides.approval_policy =
Some(codex_protocol::protocol::AskForApproval::UnlessTrusted);
app.config
.permissions
.set_permission_profile(PermissionProfile::read_only())?;
app.runtime_permission_profile_override =
Some(RuntimePermissionProfileOverride::from_config(&app.config));
app.runtime_approval_policy_override = Some(RuntimeApprovalPolicyOverride::Explicit(
AskForApproval::UnlessTrusted,
));
let mut tui = crate::tui::test_support::make_test_tui()?;
for (thread_id, expected_permissions, expected_approval) in [
(
loaded.thread_id,
loaded.permission_profile,
loaded.approval_policy,
),
(
thread_ids[1],
PermissionProfile::read_only(),
codex_app_server_protocol::AskForApproval::UnlessTrusted,
),
(
thread_ids[2],
PermissionProfile::read_only(),
codex_app_server_protocol::AskForApproval::UnlessTrusted,
),
(
thread_ids[3],
PermissionProfile::read_only(),
codex_app_server_protocol::AskForApproval::UnlessTrusted,
),
] {
if thread_id == thread_ids[2] {
// A session-only /permissions choice must survive subsequent cold resumes.
std::fs::write(
app.config.codex_home.join("config.toml"),
"approvals_reviewer = \"auto_review\"\n[tui]\nresume_cwd = \"session\"\n",
)?;
app.harness_overrides.sandbox_mode =
Some(codex_protocol::config_types::SandboxMode::WorkspaceWrite);
app.harness_overrides.approval_policy =
Some(codex_protocol::protocol::AskForApproval::Never);
app.runtime_permission_profile_override =
Some(RuntimePermissionProfileOverride::from_config(&app.config));
app.runtime_approval_policy_override = Some(RuntimeApprovalPolicyOverride::Explicit(
AskForApproval::UnlessTrusted,
));
}
app.select_agents_overview_thread(&mut tui, &mut app_server, thread_id)
.await?;
let observed = app_server
.resume_thread(
app.config.clone(),
thread_id,
crate::app_server_session::ResumeModelSettings::PreserveExistingThread,
)
.await?
.session;
assert_eq!(
(
app.primary_thread_id,
observed.permission_profile,
observed.approval_policy,
observed.approvals_reviewer,
),
(
Some(thread_id),
expected_permissions,
expected_approval,
ApprovalsReviewer::User
),
);
}
let requirements = app.config.codex_home.join("requirements.toml");
std::fs::write(
&requirements,
"allowed_approvals_reviewers = [\"auto_review\"]\n",
)?;
app.loader_overrides.system_requirements_path = Some(requirements.to_path_buf());
app.select_agents_overview_thread(&mut tui, &mut app_server, thread_ids[4])
.await?;
assert_eq!(
(
app.primary_thread_id,
app_server
.thread_read(thread_ids[4], /*include_turns*/ false)
.await?
.status
),
(
Some(thread_ids[3]),
codex_app_server_protocol::ThreadStatus::NotLoaded
),
);
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn overview_cold_resume_honors_working_directory_selection() -> Result<()> {
for (mode, cli_cwd, runtime_cwd) in [
("current", false, false),
("session", true, false),
("session", true, true),
("session", false, false),
] {
let mut app = make_test_app().await;
let chosen = app.config.codex_home.join("chosen");
let overridden = app.config.codex_home.join("overridden");
std::fs::create_dir(&chosen)?;
std::fs::create_dir(&overridden)?;
std::fs::write(
app.config.codex_home.join("config.toml"),
format!("[tui]\nresume_cwd = \"{mode}\"\n"),
)?;
crate::legacy_core::config::set_project_trust_level(
app.config.codex_home.as_path(),
&chosen,
codex_protocol::config_types::TrustLevel::Trusted,
)
.map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?;
let thread_id = ThreadId::from_string(
&app_test_support::create_fake_rollout(
&app.config.codex_home,
"2025-01-01T12-00-00",
"2025-01-01T12:00:00Z",
"Historical session",
Some(&app.config.model_provider_id),
/*git_info*/ None,
)
.expect("create historical session"),
)?;
let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?;
app.launch_cwd = chosen.to_path_buf();
app.harness_overrides.cwd = cli_cwd.then(|| {
if runtime_cwd {
overridden.to_path_buf()
} else {
chosen.to_path_buf()
}
});
app.runtime_working_directory_override = runtime_cwd.then(|| chosen.to_path_buf());
let expected_cwd = if mode == "current" || cli_cwd || runtime_cwd {
chosen
} else {
test_path_buf("/").abs()
};
let mut tui = crate::tui::test_support::make_test_tui()?;
app.select_agents_overview_thread(&mut tui, &mut app_server, thread_id)
.await?;
let observed = app_server
.resume_thread(
app.config.clone(),
thread_id,
crate::app_server_session::ResumeModelSettings::PreserveExistingThread,
)
.await?
.session;
assert_eq!(
(app.config.cwd.clone(), observed.cwd),
(expected_cwd.clone(), expected_cwd),
);
app_server.shutdown().await?;
}
Ok(())
}
#[tokio::test]
async fn restored_server_permission_profile_survives_cd_without_turn_override() -> Result<()> {
let mut app = make_test_app().await;
@@ -0,0 +1,336 @@
//! Retains overview membership for this TUI, independently of server subscriptions.
//! The loaded/recent seed runs at startup and after reconnect; other reads only refresh metadata.
//! Discovery merges into retained membership without evicting rows.
use super::App;
use super::agents_overview::AGENTS_OVERVIEW_VIEW_ID;
use super::app_server_event_targets::ServerNotificationThreadTarget;
use super::app_server_event_targets::server_notification_thread_target;
use crate::AppServerTarget;
use crate::app_event::AgentsOverviewThreadRefresh;
use crate::app_event::AppEvent;
use crate::app_server_session::AppServerSession;
use crate::chatwidget::ChatWidget;
use codex_app_server_client::TypedRequestError;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::SessionSource;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadListParams;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadLoadedListParams;
use codex_app_server_protocol::ThreadLoadedListResponse;
use codex_app_server_protocol::ThreadReadParams;
use codex_app_server_protocol::ThreadReadResponse;
use codex_app_server_protocol::ThreadSortKey;
use codex_app_server_protocol::ThreadSourceKind;
use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::ThreadTurnsListParams;
use codex_app_server_protocol::ThreadTurnsListResponse;
use codex_protocol::ThreadId;
use codex_protocol::protocol::SubAgentSource;
use std::collections::HashMap;
use uuid::Uuid;
impl App {
pub(super) fn track_agents_overview_notification(&mut self, notification: &ServerNotification) {
let ServerNotificationThreadTarget::Thread(thread_id) =
server_notification_thread_target(notification)
else {
return;
};
let thread = self
.agents_overview
.threads
.get_mut(&thread_id)
.and_then(Option::as_mut);
match notification {
ServerNotification::ThreadStarted(started) => {
if started.thread.ephemeral {
return;
}
let mut thread = started.thread.clone();
thread.turns.clear();
self.agents_overview.threads.insert(thread_id, Some(thread));
}
ServerNotification::ThreadArchived(_) | ServerNotification::ThreadDeleted(_) => {
self.agents_overview.threads.remove(&thread_id);
self.agents_overview.refresh_thread_ids.remove(&thread_id);
}
ServerNotification::ThreadClosed(_) => {
if let Some(thread) = thread {
thread.status = ThreadStatus::NotLoaded;
}
}
ServerNotification::ThreadStatusChanged(status) => {
if let Some(thread) = thread {
thread.status = status.status.clone();
}
}
ServerNotification::ThreadNameUpdated(name) => {
if let Some(thread) = thread {
thread.name.clone_from(&name.thread_name);
}
}
ServerNotification::ThreadSettingsUpdated(settings) => {
if let Some(thread) = thread {
thread.cwd.clone_from(&settings.thread_settings.cwd);
thread
.model_provider
.clone_from(&settings.thread_settings.model_provider);
}
}
_ => return,
}
if self.agents_overview.threads.contains_key(&thread_id) {
self.agents_overview.refresh_thread_ids.insert(thread_id);
}
if self.agents_overview.request_id.is_some() {
// Replay the latest notification of each kind after the read, in arrival order.
// This also protects sessions whose metadata has not arrived in the initial seed.
let pending = self
.agents_overview
.refresh_notifications
.entry(thread_id)
.or_default();
pending.retain(|previous| {
std::mem::discriminant(previous) != std::mem::discriminant(notification)
});
pending.push(notification.clone());
}
}
pub(super) fn refresh_agents_overview_threads(&mut self, app_server: &AppServerSession) {
self.agents_overview
.refresh_thread_ids
.extend(self.agents_overview.threads.keys());
self.start_agents_overview_refresh(app_server);
}
pub(super) fn refresh_changed_agents_overview_threads(
&mut self,
app_server: &AppServerSession,
) {
if self
.chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID)
.is_none()
|| (!self.agents_overview.initialized && self.agents_overview.request_id.is_none())
{
return;
}
self.start_agents_overview_refresh(app_server);
}
fn start_agents_overview_refresh(&mut self, app_server: &AppServerSession) {
let visible = self
.chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID)
.is_some();
if !visible
&& (self.agents_overview.initialized
|| matches!(self.app_server_target, AppServerTarget::Embedded))
{
return;
}
if self.agents_overview.request_id.is_some() {
self.agents_overview.refresh_pending = true;
return;
}
if self.agents_overview.initialized && self.agents_overview.refresh_thread_ids.is_empty() {
return;
}
let request_id = Uuid::new_v4();
self.agents_overview.request_id = Some(request_id);
let initialized = self.agents_overview.initialized;
let mut thread_ids = std::mem::take(&mut self.agents_overview.refresh_thread_ids);
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
let refresh_task = tokio::spawn(async move {
let result = async {
let mut threads = HashMap::new();
let mut recent_seed_complete = true;
if !initialized {
let loaded = request_handle.request_typed::<ThreadLoadedListResponse>(
ClientRequest::ThreadLoadedList {
request_id: RequestId::String(Uuid::new_v4().to_string()),
params: ThreadLoadedListParams {
cursor: None,
limit: None,
},
},
);
let list_recent = async |source_kinds: Vec<ThreadSourceKind>| {
let mut recent = Vec::new();
let mut cursor = None;
let mut sort_key = ThreadSortKey::RecencyAt;
while recent.len() < 20 {
let page = match request_handle
.request_typed::<ThreadListResponse>(ClientRequest::ThreadList {
request_id: RequestId::String(Uuid::new_v4().to_string()),
params: ThreadListParams {
cursor,
limit: Some(20),
sort_key: Some(sort_key),
sort_direction: None,
model_providers: Some(Vec::new()),
source_kinds: Some(source_kinds.clone()),
archived: Some(false),
section_id: None,
project_id: None,
parent_thread_id: None,
ancestor_thread_id: None,
cwd: None,
use_state_db_only: false,
search_term: None,
},
})
.await
{
Err(TypedRequestError::Server { source, .. })
if sort_key == ThreadSortKey::RecencyAt
&& matches!(source.code, -32600 | -32602)
&& source.message.contains("recency_at") =>
{
// Older servers can still provide their activity-sorted history.
sort_key = ThreadSortKey::UpdatedAt;
cursor = None;
recent.clear();
continue;
}
result => result?,
};
recent.extend(
page.data
.into_iter()
.filter(|thread| {
!thread.ephemeral
&& thread.parent_thread_id.is_none()
&& !matches!(
thread.source,
SessionSource::SubAgent(
SubAgentSource::ThreadSpawn { .. }
)
)
})
.take(20 - recent.len()),
);
cursor = page.next_cursor;
if cursor.is_none() {
break;
}
}
Ok::<_, TypedRequestError>(recent)
};
let recent = async {
// Default interactive sources include Atlas/ChatGPT, which have no
// explicit source kind. Exec/AppServer require a separate query.
let (interactive, non_interactive) = tokio::join!(
list_recent(Vec::new()),
list_recent(vec![ThreadSourceKind::Exec, ThreadSourceKind::AppServer]),
);
let mut recent = interactive?;
recent.extend(non_interactive?);
recent.sort_by(|left, right| {
right
.recency_at
.unwrap_or(right.updated_at)
.cmp(&left.recency_at.unwrap_or(left.updated_at))
.then_with(|| right.id.cmp(&left.id))
});
recent.truncate(20);
Ok::<_, TypedRequestError>(recent)
};
let (loaded, recent) = tokio::join!(loaded, recent);
let loaded = loaded.map_err(|error| error.to_string())?;
let recent = recent.unwrap_or_else(|error| {
tracing::warn!(%error, "failed to list recent agent threads");
recent_seed_complete = false;
Vec::new()
});
thread_ids.extend(
loaded
.data
.into_iter()
.filter_map(|id| ThreadId::from_string(&id).ok()),
);
// Keep list metadata even if a subsequent read fails transiently.
for thread in recent {
if let Ok(thread_id) = ThreadId::from_string(&thread.id) {
threads.insert(thread_id, Some(thread));
thread_ids.insert(thread_id);
}
}
}
let mut reads = tokio::task::JoinSet::new();
for thread_id in thread_ids {
threads.entry(thread_id).or_default();
let request_handle = request_handle.clone();
reads.spawn(async move {
match request_handle
.request_typed::<ThreadReadResponse>(ClientRequest::ThreadRead {
request_id: RequestId::String(Uuid::new_v4().to_string()),
params: ThreadReadParams {
thread_id: thread_id.to_string(),
include_turns: false,
},
})
.await
{
Ok(mut response) => {
if let Ok(turns) = request_handle
.request_typed::<ThreadTurnsListResponse>(
ClientRequest::ThreadTurnsList {
request_id: RequestId::String(
Uuid::new_v4().to_string(),
),
params: ThreadTurnsListParams {
thread_id: thread_id.to_string(),
cursor: None,
limit: Some(1),
sort_direction: None,
items_view: None,
},
},
)
.await
&& let Some(ThreadItem::UserMessage { content, .. }) =
turns.data.first().and_then(|turn| turn.items.first())
{
response.thread.preview =
ChatWidget::user_message_display_from_inputs(content)
.message;
}
Some((thread_id, response.thread))
}
Err(error) => {
tracing::warn!(%thread_id, %error, "failed to read agent thread");
None
}
}
});
if reads.len() >= 16
&& let Some(Ok(Some((thread_id, thread)))) = reads.join_next().await
{
threads.insert(thread_id, Some(thread));
}
}
while let Some(result) = reads.join_next().await {
if let Ok(Some((thread_id, thread))) = result {
threads.insert(thread_id, Some(thread));
}
}
Ok(AgentsOverviewThreadRefresh {
threads,
recent_seed_complete,
})
}
.await;
app_event_tx.send(AppEvent::AgentsOverviewThreadsLoaded { request_id, result });
});
self.agents_overview.refresh_task = Some(refresh_task.abort_handle());
}
}
+1 -16
View File
@@ -1,4 +1,4 @@
//! Dashboard for inspecting and managing the daemon's active tasks.
//! Dashboard for inspecting and managing the TUI's retained daemon tasks.
use super::agents_overview::AGENTS_OVERVIEW_VIEW_ID;
use crate::app_event::AppEvent;
@@ -89,7 +89,6 @@ pub(super) struct AgentsOverviewViewState {
search: String,
searching: bool,
pub(super) status_grouping: bool,
pub(super) refresh_task: Option<tokio::task::AbortHandle>,
pub(super) renaming: bool,
}
@@ -104,20 +103,6 @@ pub(super) struct AgentsOverviewView {
agents_keymap: AgentsKeymap,
}
impl Drop for AgentsOverviewView {
fn drop(&mut self) {
if let Some(task) = self
.state
.lock()
.unwrap_or_else(PoisonError::into_inner)
.refresh_task
.take()
{
task.abort();
}
}
}
impl AgentsOverviewView {
pub(super) fn new(
rows: Vec<AgentsOverviewRow>,
+3 -1
View File
@@ -137,6 +137,7 @@ impl App {
.entry(thread_id)
.or_default();
}
self.track_agents_overview_notification(&notification);
if matches!(
&notification,
ServerNotification::ThreadStarted(_)
@@ -147,7 +148,8 @@ impl App {
| ServerNotification::ThreadDeleted(_)
| ServerNotification::ThreadClosed(_)
) {
self.refresh_agents_overview_threads(app_server_client);
self.repaint_agents_overview();
self.refresh_changed_agents_overview_threads(app_server_client);
}
match &notification {
ServerNotification::ServerRequestResolved(notification) => {
+31 -6
View File
@@ -183,7 +183,8 @@ impl App {
self.config = config;
if let Some(policy) = approval_policy {
self.runtime_approval_policy_override = Some(policy);
self.runtime_approval_policy_override =
Some(RuntimeApprovalPolicyOverride::Explicit(policy));
self.chat_widget.set_approval_policy(policy);
}
if let Err(err) = self.chat_widget.set_permission_profile_with_active_profile(
@@ -236,7 +237,7 @@ impl App {
let mut config = self
.rebuild_config_for_cwd(self.chat_widget.config_ref().cwd.to_path_buf())
.await?;
self.apply_runtime_policy_overrides(&mut config);
self.apply_runtime_policy_overrides(&mut config, RuntimePolicyOverrideScope::All);
self.config = config;
self.chat_widget.sync_plugin_mentions_config(&self.config);
Ok(())
@@ -297,16 +298,40 @@ impl App {
}
}
pub(super) fn apply_runtime_policy_overrides(&mut self, config: &mut Config) {
if let Some(policy) = self.runtime_approval_policy_override.as_ref()
&& let Err(err) = config.permissions.approval_policy.set(policy.to_core())
pub(super) fn apply_runtime_policy_overrides(
&mut self,
config: &mut Config,
scope: RuntimePolicyOverrideScope,
) {
if let Some(policy) = self.runtime_approval_policy_override
&& (scope == RuntimePolicyOverrideScope::All
|| matches!(policy, RuntimeApprovalPolicyOverride::Explicit(_)))
&& let Err(err) = config
.permissions
.approval_policy
.set(policy.policy().to_core())
{
tracing::warn!(%err, "failed to carry forward approval policy override");
self.chat_widget.add_error_message(format!(
"Failed to carry forward approval policy override: {err}"
));
}
if let Some(profile_override) = self.runtime_permission_profile_override.as_ref() {
if let Some(profile_override) = self.runtime_permission_profile_override.as_ref()
&& (scope == RuntimePolicyOverrideScope::All
|| profile_override.turn_override
== RuntimePermissionProfileTurnOverride::LegacySandbox)
{
match config
.config_layer_stack
.requirements()
.approvals_reviewer
.can_set(&profile_override.approvals_reviewer)
{
Ok(()) => config.approvals_reviewer = profile_override.approvals_reviewer,
Err(error) => self.chat_widget.add_error_message(format!(
"Failed to carry forward approvals reviewer: {error}"
)),
}
match config
.permissions
.set_permission_profile_from_session_snapshot(
+2 -1
View File
@@ -2293,7 +2293,8 @@ impl App {
self.config = config;
let approval_policy =
AskForApproval::from(self.config.permissions.approval_policy.value());
self.runtime_approval_policy_override = Some(approval_policy);
self.runtime_approval_policy_override =
Some(RuntimeApprovalPolicyOverride::Explicit(approval_policy));
self.chat_widget.set_approval_policy(approval_policy);
self.sync_active_thread_permission_settings_to_cached_session()
.await;
+1 -1
View File
@@ -51,7 +51,7 @@ impl App {
self.chat_widget.set_approval_policy(AskForApproval::from(config.permissions.approval_policy.value()));
self.config.permissions = config.permissions.clone();
self.set_approvals_reviewer_in_app_and_widget(config.approvals_reviewer);
self.runtime_approval_policy_override = selection.approval_policy;
self.runtime_approval_policy_override = selection.approval_policy.map(RuntimeApprovalPolicyOverride::Explicit);
self.runtime_permission_profile_override = Some(RuntimePermissionProfileOverride::from_config(&config));
self.sync_active_thread_permission_settings_to_cached_session().await;
self.insert_history_cell(tui, Box::new(history_cell::new_info_event(format!("Permissions updated to {}", selection.display_label), /*hint*/ None)));
+20 -7
View File
@@ -126,7 +126,9 @@ impl App {
}
// 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
if let Some(policy) = self
.runtime_approval_policy_override
.map(RuntimeApprovalPolicyOverride::policy)
&& policy == cached.approval_policy
{
session.approval_policy = policy;
@@ -169,6 +171,12 @@ impl App {
self.overlay = None;
self.commit_animation = None;
self.clear_recap_request(crate::app_event::RecapTrigger::Manual);
if let Some(task) = self.agents_overview.refresh_task.take() {
task.abort();
}
self.agents_overview.request_id = None;
self.agents_overview.refresh_pending = false;
self.agents_overview.refresh_notifications.clear();
self.reconnect.presentation = if self
.chat_widget
.selected_index_for_active_view(agents_overview::AGENTS_OVERVIEW_VIEW_ID)
@@ -176,12 +184,7 @@ impl App {
{
if let Ok(mut state) = self.agents_overview.view_state.lock() {
state.connection_notice = Some("Reconnecting — agent list is stale");
if let Some(task) = state.refresh_task.take() {
task.abort();
}
}
self.agents_overview.request_id = None;
self.agents_overview.refresh_pending = false;
ReconnectPresentation::Overview
} else {
self.chat_widget
@@ -342,11 +345,21 @@ impl App {
self.replace_chat_widget(ChatWidget::new_with_app_event(init));
self.chat_widget.restore_reconnected_input(input);
}
// Discover tasks whose notifications were missed, without clearing retained rows.
// A hidden overview performs this discovery when it is next opened.
self.agents_overview.initialized = false;
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);
let threads = self
.agents_overview
.threads
.values()
.flatten()
.cloned()
.collect();
let view = self.agents_overview_view(threads, selected);
self.chat_widget.show_bottom_pane_view(Box::new(view));
self.refresh_agents_overview_threads(app_server);
}
+101
View File
@@ -0,0 +1,101 @@
//! Shared configuration and working-directory resolution for ordinary and overview cold resumes.
//! Keeps CLI/runtime cwd precedence, remote-workspace checks, and interactive prompts aligned.
use super::*;
use codex_config::types::ResumeCwdMode;
impl App {
pub(super) async fn resume_config_for_target(
&mut self,
tui: &mut tui::Tui,
app_server: &AppServerSession,
target_session: &SessionTarget,
) -> std::result::Result<Config, AppRunControl> {
self.refresh_in_memory_config_from_disk_best_effort("resuming a thread")
.await;
let cwd_override = self
.runtime_working_directory_override
.as_deref()
.or(self.harness_overrides.cwd.as_deref())
.or_else(|| app_server.remote_cwd_override());
let resume_cwd_mode = crate::session_resume::effective_resume_cwd_mode(
self.config.tui_resume_cwd,
cwd_override,
);
let remembered_current_cwd = cwd_override.unwrap_or(self.launch_cwd.as_path());
let current_cwd = if matches!(resume_cwd_mode, Some(ResumeCwdMode::Current)) {
remembered_current_cwd.to_path_buf()
} else {
self.config.cwd.to_path_buf()
};
let uses_remote_workspace_or_environment = crate::uses_remote_workspace_or_environment(
&self.app_server_target,
&self.environment_manager,
);
if uses_remote_workspace_or_environment
&& self.harness_overrides.cwd.is_none()
&& app_server.remote_cwd_override().is_none()
&& matches!(resume_cwd_mode, Some(ResumeCwdMode::Current))
{
self.chat_widget.add_error_message(
"`tui.resume_cwd = \"current\"` requires `--cd` when using a remote workspace"
.to_string(),
);
return Err(AppRunControl::Continue);
}
let resume_cwd = if self.app_server_target.uses_remote_workspace() {
current_cwd.clone()
} else {
let outcome = crate::session_resume::resolve_cwd_for_resume_or_fork(
tui,
&self.config,
self.state_db.as_deref(),
target_session,
CwdPromptAction::Resume,
crate::session_resume::ResumeCwdContext {
current_cwd: &current_cwd,
remembered_current_cwd,
allow_remember_current: !uses_remote_workspace_or_environment
|| cwd_override.is_some(),
mode: resume_cwd_mode,
},
)
.await;
match outcome {
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to determine working directory for resume: {err}"
));
return Err(AppRunControl::Continue);
}
Ok(crate::session_resume::ResolveCwdOutcome::Continue(Some(cwd)))
| Ok(crate::session_resume::ResolveCwdOutcome::ContinueAfterPrompt(cwd)) => cwd,
Ok(crate::session_resume::ResolveCwdOutcome::Continue(None)) => current_cwd.clone(),
Ok(crate::session_resume::ResolveCwdOutcome::Exit) => {
return Err(AppRunControl::Exit(ExitReason::UserRequested));
}
}
};
let (config_current_cwd, config_resume_cwd) =
if self.app_server_target.uses_remote_workspace() {
let local_config_cwd = self.config.cwd.to_path_buf();
(local_config_cwd.clone(), local_config_cwd)
} else {
(current_cwd, resume_cwd)
};
let resume_config = match self
.rebuild_config_for_resume_or_fallback(&config_current_cwd, config_resume_cwd)
.await
{
Ok(cfg) => cfg,
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to rebuild configuration for resume: {err}"
));
return Err(AppRunControl::Continue);
}
};
Ok(resume_config)
}
}
+4 -83
View File
@@ -13,7 +13,6 @@ use super::app_server_event_targets::server_request_thread_id;
use super::*;
use crate::app_server_session::source_agent_path;
use crate::app_server_session::thread_blocks_direct_input;
use codex_config::types::ResumeCwdMode;
use std::collections::HashSet;
#[derive(Clone, Copy)]
@@ -1087,92 +1086,14 @@ impl App {
return Ok(AppRunControl::Continue);
}
self.refresh_in_memory_config_from_disk_best_effort("resuming a thread")
.await;
let cwd_override = self
.runtime_working_directory_override
.as_deref()
.or(self.harness_overrides.cwd.as_deref())
.or_else(|| app_server.remote_cwd_override());
let resume_cwd_mode = crate::session_resume::effective_resume_cwd_mode(
self.config.tui_resume_cwd,
cwd_override,
);
let remembered_current_cwd = cwd_override.unwrap_or(self.launch_cwd.as_path());
let current_cwd = if matches!(resume_cwd_mode, Some(ResumeCwdMode::Current)) {
remembered_current_cwd.to_path_buf()
} else {
self.config.cwd.to_path_buf()
};
let uses_remote_workspace_or_environment = crate::uses_remote_workspace_or_environment(
&self.app_server_target,
&self.environment_manager,
);
if uses_remote_workspace_or_environment
&& self.harness_overrides.cwd.is_none()
&& app_server.remote_cwd_override().is_none()
&& matches!(resume_cwd_mode, Some(ResumeCwdMode::Current))
{
self.chat_widget.add_error_message(
"`tui.resume_cwd = \"current\"` requires `--cd` when using a remote workspace"
.to_string(),
);
return Ok(AppRunControl::Continue);
}
let resume_cwd = if self.app_server_target.uses_remote_workspace() {
current_cwd.clone()
} else {
let outcome = crate::session_resume::resolve_cwd_for_resume_or_fork(
tui,
&self.config,
self.state_db.as_deref(),
&target_session,
CwdPromptAction::Resume,
crate::session_resume::ResumeCwdContext {
current_cwd: &current_cwd,
remembered_current_cwd,
allow_remember_current: !uses_remote_workspace_or_environment
|| cwd_override.is_some(),
mode: resume_cwd_mode,
},
)
.await;
match outcome {
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to determine working directory for resume: {err}"
));
return Ok(AppRunControl::Continue);
}
Ok(crate::session_resume::ResolveCwdOutcome::Continue(Some(cwd)))
| Ok(crate::session_resume::ResolveCwdOutcome::ContinueAfterPrompt(cwd)) => cwd,
Ok(crate::session_resume::ResolveCwdOutcome::Continue(None)) => current_cwd.clone(),
Ok(crate::session_resume::ResolveCwdOutcome::Exit) => {
return Ok(AppRunControl::Exit(ExitReason::UserRequested));
}
}
};
let (config_current_cwd, config_resume_cwd) =
if self.app_server_target.uses_remote_workspace() {
let local_config_cwd = self.config.cwd.to_path_buf();
(local_config_cwd.clone(), local_config_cwd)
} else {
(current_cwd, resume_cwd)
};
let mut resume_config = match self
.rebuild_config_for_resume_or_fallback(&config_current_cwd, config_resume_cwd)
.resume_config_for_target(tui, app_server, &target_session)
.await
{
Ok(cfg) => cfg,
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to rebuild configuration for resume: {err}"
));
return Ok(AppRunControl::Continue);
}
Ok(config) => config,
Err(control) => return Ok(control),
};
self.apply_runtime_policy_overrides(&mut resume_config);
self.apply_runtime_policy_overrides(&mut resume_config, RuntimePolicyOverrideScope::All);
let summary = session_summary(
self.chat_widget.token_usage(),
+2
View File
@@ -551,6 +551,8 @@ See the Codex keymap documentation for supported actions and examples."
}
if start_in_agents_overview {
app.open_agents_overview(&app_server);
} else if !matches!(app.app_server_target, AppServerTarget::Embedded) {
app.refresh_agents_overview_threads(&app_server);
}
if let Some(entry) = startup_hooks_browser {
app.chat_widget.open_hooks_browser(entry);
@@ -21,7 +21,9 @@ async fn reconnect_restores_history_permissions_and_keeps_old_input_paused() ->
app.config
.permissions
.set_permission_profile(PermissionProfile::read_only())?;
app.runtime_approval_policy_override = Some(AskForApproval::OnRequest);
app.runtime_approval_policy_override = Some(RuntimeApprovalPolicyOverride::Explicit(
AskForApproval::OnRequest,
));
app.runtime_permission_profile_override =
Some(RuntimePermissionProfileOverride::from_config(&app.config));
app.active_thread_id = Some(id);
@@ -5,7 +5,9 @@ use super::disconnect::serve_reconnect_requests;
use super::*;
use crate::app::reconnect::ReconnectPresentation;
use crate::app::reconnect::reconnect;
use crate::app_event::AgentsOverviewThreadRefresh;
use crate::app_server_session::ThreadParamsMode;
use codex_app_server_client::AppServerEvent;
use pretty_assertions::assert_eq;
use serde_json::json;
@@ -18,10 +20,11 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv
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),
for (previous_thread, changed_child_permissions, overview_initialized) in [
(None, false, false),
(None, false, true),
(Some(ThreadId::new()), false, true),
(Some(ThreadId::new()), true, true),
] {
let (mut app, mut events, _) = make_test_app_with_channels().await;
app.keymap.app.open_agents = vec![crate::key_hint::alt(KeyCode::Char('a'))];
@@ -37,7 +40,9 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv
app.config
.permissions
.set_permission_profile(PermissionProfile::read_only())?;
app.runtime_approval_policy_override = Some(AskForApproval::OnRequest);
app.runtime_approval_policy_override = Some(RuntimeApprovalPolicyOverride::Explicit(
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());
@@ -118,7 +123,12 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv
},
)?,
];
app.agents_overview.threads = stale.clone();
let stale_threads = stale
.iter()
.map(|thread| Ok((ThreadId::from_string(&thread.id)?, Some(thread.clone()))))
.collect::<Result<HashMap<_, _>>>()?;
app.agents_overview.threads = stale_threads.clone();
app.agents_overview.initialized = overview_initialized;
let view = app.agents_overview_view(
stale.clone(),
Some(if previous_thread.is_some() {
@@ -135,7 +145,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv
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());
app.agents_overview.refresh_task = Some(refresh.abort_handle());
let directory = tempfile::tempdir()?;
let socket_path = directory.path().join("daemon.sock");
let listener = UnixListener::bind(&socket_path)?;
@@ -187,6 +197,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv
Some(json!({"result": {"turn": {"id": "fresh", "items": [], "status": "inProgress"}}}))
}
"thread/loaded/list" => Some(json!({"result": {"data": [selected, added], "nextCursor": null}})),
"thread/list" => Some(json!({"result": {"data": [], "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}})),
@@ -196,6 +207,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv
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" if request.params.as_ref().unwrap()["threadId"] == vanished.to_string() => Some(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}"),
}
@@ -232,7 +244,14 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv
),
(None, false)
);
app.apply_agents_overview_thread_refresh(&session, stale_request, Ok(Vec::new()));
app.apply_agents_overview_thread_refresh(
&session,
stale_request,
Ok(AgentsOverviewThreadRefresh {
threads: HashMap::new(),
recent_seed_complete: false,
}),
);
assert_eq!(app.agents_overview.visible_thread_ids.len(), 2);
let mut tui = crate::tui::test_support::make_test_tui()?;
app.handle_tui_event(
@@ -293,6 +312,18 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv
Ok::<_, color_eyre::Report>(())
})
.await??;
assert!(app.agents_overview.visible_thread_ids.contains(&vanished));
// Missing metadata alone does not evict a retained row; an archive notification does.
app.handle_app_server_event(
&session,
AppServerEvent::ServerNotification(Box::new(ServerNotification::ThreadArchived(
ThreadArchivedNotification {
thread_id: vanished.to_string(),
},
))),
)
.await;
let index = app
.chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID)
@@ -336,7 +367,14 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv
.connection_notice
.is_none()
);
app.apply_agents_overview_thread_refresh(&session, stale_request, Ok(stale));
app.apply_agents_overview_thread_refresh(
&session,
stale_request,
Ok(AgentsOverviewThreadRefresh {
threads: stale_threads,
recent_seed_complete: true,
}),
);
assert!(!app.agents_overview.visible_thread_ids.contains(&vanished));
if let Some(id) = previous_thread {
@@ -407,7 +445,9 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv
&PermissionProfile::Disabled
);
if changed_child_permissions {
app.runtime_approval_policy_override = Some(AskForApproval::Never);
app.runtime_approval_policy_override = Some(
RuntimeApprovalPolicyOverride::Explicit(AskForApproval::Never),
);
app.runtime_permission_profile_override = Some(
RuntimePermissionProfileOverride::from_config(app.chat_widget.config_ref()),
);
@@ -4,6 +4,7 @@ use app_test_support::create_fake_paginated_rollout;
use app_test_support::create_fake_parented_rollout_with_source;
use app_test_support::create_fake_rollout;
use app_test_support::rollout_path;
use codex_app_server_client::AppServerEvent;
use codex_app_server_protocol::ClientNotification;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::JSONRPCError;
@@ -47,6 +48,7 @@ enum HistoryCapabilities {
LegacyOnlyUnsupportedVariant,
LegacyDynamicToolsAndHistory,
ForkHydrationFails,
ThreadListFails,
}
/// Returns and resets `(thread/loaded/list, thread/read)` request counts.
@@ -127,6 +129,7 @@ async fn start_recording_app_server_with_history(
let mut websocket = accept_async(stream).await?;
let mut inventories = usize::from(failed_thread_name == Some("background"));
let mut reject_detach = false;
let mut reject_thread_list = history_capabilities == HistoryCapabilities::ThreadListFails;
while let Some(frame) = websocket.next().await {
let Message::Text(text) = frame? else {
continue;
@@ -182,7 +185,30 @@ async fn start_recording_app_server_with_history(
.is_some_and(|tools| {
tools.iter().any(|tool| tool["type"] == "namespace")
});
let response = if reject_dynamic_tools {
let response = if request.method == "thread/list"
&& std::mem::take(&mut reject_thread_list)
{
JSONRPCMessage::Error(JSONRPCError {
id: request_id,
error: JSONRPCErrorError {
code: -32603,
data: None,
message: "thread listing unavailable".to_string(),
},
})
} else if history_capabilities == HistoryCapabilities::LegacyOnly
&& request.method == "thread/list"
&& params.is_some_and(|params| params["sortKey"] == "recency_at")
{
JSONRPCMessage::Error(JSONRPCError {
id: request_id,
error: JSONRPCErrorError {
code: -32602,
data: None,
message: "unknown variant `recency_at`".to_string(),
},
})
} else if reject_dynamic_tools {
JSONRPCMessage::Error(JSONRPCError {
id: request_id,
error: JSONRPCErrorError {
@@ -2367,6 +2393,88 @@ async fn agents_overview_stop_uses_history_mode_for_turn_lookup() -> Result<()>
Ok(())
}
#[tokio::test]
async fn agents_overview_seeds_loaded_threads_when_recent_listing_is_unavailable() -> Result<()> {
for (capabilities, expected_sort_keys) in [
(
HistoryCapabilities::LegacyOnly,
vec!["recency_at", "recency_at", "updated_at", "updated_at"],
),
(
HistoryCapabilities::ThreadListFails,
vec!["recency_at", "recency_at", "recency_at", "recency_at"],
),
] {
let (mut app, _codex_home) = make_history_test_app().await?;
let (mut app_server, requests, proxy) = start_recording_app_server_with_history(
&app.config,
capabilities,
/*blocked_thread_list*/ None,
/*failed_thread_name*/ None,
crate::app_server_session::ThreadParamsMode::Embedded,
)
.await?;
let started = app_server.start_thread(&app.config).await?;
app.app_server_target = AppServerTarget::LocalDaemon {
endpoint: crate::RemoteAppServerEndpoint::UnixSocket {
socket_path: test_path_buf("/tmp/unused.sock").abs(),
},
};
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
app.app_event_tx = AppEventSender::new(tx);
for attempt in 0..2 {
if attempt == 0 {
app.refresh_agents_overview_threads(&app_server);
} else {
app.open_agents_overview(&app_server);
}
let Some(AppEvent::AgentsOverviewThreadsLoaded { request_id, result }) =
tokio::time::timeout(Duration::from_secs(10), rx.recv()).await?
else {
panic!("expected overview result")
};
app.apply_agents_overview_thread_refresh(&app_server, request_id, result);
assert_eq!(
app.agents_overview
.threads
.keys()
.copied()
.collect::<Vec<_>>(),
vec![started.session.thread_id]
);
assert_eq!(
app.agents_overview.initialized,
capabilities != HistoryCapabilities::ThreadListFails || attempt > 0
);
if attempt == 0 {
app.handle_app_server_event(
&app_server,
AppServerEvent::ServerNotification(Box::new(
ServerNotification::ThreadStatusChanged(
codex_app_server_protocol::ThreadStatusChangedNotification {
thread_id: started.session.thread_id.to_string(),
status: codex_app_server_protocol::ThreadStatus::Idle,
},
),
)),
)
.await;
assert!(app.agents_overview.request_id.is_none());
}
}
let list_requests = recorded_params(&requests, "thread/list");
let mut sort_keys = list_requests
.iter()
.map(|params| params["sortKey"].as_str().unwrap())
.collect::<Vec<_>>();
sort_keys.sort_unstable();
assert_eq!(sort_keys, expected_sort_keys);
app_server.shutdown().await?;
proxy.await??;
}
Ok(())
}
#[tokio::test]
async fn agents_overview_stop_uses_full_history_after_legacy_negotiation() -> Result<()> {
let (mut app, _codex_home) = make_history_test_app().await?;
@@ -2698,8 +2806,9 @@ async fn changing_directory_preserves_project_trust_permissions_history_and_hook
matches!(kind, "approval" | "profile" | "reviewer").then_some(requirements.clone());
app.harness_overrides.permission_profile =
(kind != "named").then_some(PermissionProfile::workspace_write());
app.runtime_approval_policy_override =
(kind == "approval").then_some(AskForApproval::OnRequest);
app.runtime_approval_policy_override = (kind == "approval").then_some(
RuntimeApprovalPolicyOverride::Explicit(AskForApproval::OnRequest),
);
let mut profile = RuntimePermissionProfileOverride::from_config(&app.config);
profile.active_permission_profile =
(kind == "named").then(|| ActivePermissionProfile::new("dev"));
@@ -6,6 +6,7 @@ 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.
• The rename target disappeared. Unsubmitted title: Keep this task draft!
› latest draft
gpt-test default · /tmp/project
+1
View File
@@ -1309,6 +1309,7 @@ impl App {
self.recap.reset_for_new_thread(Instant::now());
}
self.primary_thread_id = Some(thread_id);
self.agents_overview.threads.entry(thread_id).or_default();
self.primary_session_configured = Some(session.clone());
self.upsert_agent_picker_thread(
thread_id, /*agent_nickname*/ None, /*agent_role*/ None,
+4 -2
View File
@@ -73,7 +73,7 @@ impl App {
{
return self.working_directory_error("Permission profile cannot be preserved by /cd.");
}
self.apply_runtime_policy_overrides(&mut config);
self.apply_runtime_policy_overrides(&mut config, RuntimePolicyOverrideScope::All);
if self.runtime_permission_profile_override.is_some() {
let reviewer = self.config.approvals_reviewer;
let reviewers = &config.config_layer_stack.requirements().approvals_reviewer;
@@ -83,7 +83,9 @@ impl App {
config.approvals_reviewer = reviewer;
}
let actual = config.permissions.approval_policy.value();
let approval = self.runtime_approval_policy_override;
let approval = self
.runtime_approval_policy_override
.map(RuntimeApprovalPolicyOverride::policy);
let profile = self.runtime_permission_profile_override.as_ref();
if approval.is_some_and(|p| actual != p.to_core())
|| profile.is_some_and(|profile| !profile.matches_config(&config))
+8 -2
View File
@@ -213,15 +213,21 @@ pub(crate) enum RecapTrigger {
Manual,
}
#[derive(Debug)]
pub(crate) struct AgentsOverviewThreadRefresh {
pub(crate) threads: std::collections::HashMap<ThreadId, Option<Thread>>,
pub(crate) recent_seed_complete: bool,
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug, IntoStaticStr)]
pub(crate) enum AppEvent {
/// Open the daemon-wide overview of loaded root sessions.
/// Open the daemon-wide overview of recent and locally retained root sessions.
OpenAgentsOverview,
/// Update the daemon-wide overview after a background thread listing finishes.
AgentsOverviewThreadsLoaded {
request_id: Uuid,
result: Result<Vec<Thread>, String>,
result: Result<AgentsOverviewThreadRefresh, String>,
},
/// Switch to a root session selected from the shared dashboard.
SelectAgentsOverviewThread {
@@ -1,14 +1,14 @@
---
source: tui/src/app/agents_overview_tests.rs
expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&test_path_display(\"/tmp/project\"), \"/tmp/project\")"
expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{project} 3\"),\n&normalized_project_group).replace(&project, \"/tmp/project\")"
---
Agent command center
1 need input 1 working 0 ready
────────────────────────────────────────────────────────────────────────────────────────────
/tmp/project 2 │ Task details
/tmp/project 3 │ Task details
› ● Build the dashboard current Needs input │
● Repair authentication Working │ Build the dashboard
│ ● Needs input
✓ Review yesterday's changes Finished │ ● Needs input
│
│ Project
│ /tmp/project