Remove core protocol dependency [1/2] (#20324)

## Why

This stack moves `codex-tui` away from the core protocol event surface
and toward app-server API shapes plus TUI-owned local models. This first
PR sets up the lower-risk foundation: it introduces the local model
surface and extracts app-server event routing into focused TUI modules
while preserving the existing behavior for the larger migration in PR2.

This PR is part 1 of a 2-PR stack:

1. Add TUI-owned replacement models and extract app-server event
routing.
2. Move the active TUI flow to app-server notifications and delete
obsolete adapter code.

## What changed

- Added TUI-owned approval, diff, session state, session resume, token
usage, and user-message models.
- Added `app/app_server_event_targets.rs` and `app/app_server_events.rs`
to hold app-server event targeting and dispatch logic outside `app.rs`.
- Updated app/status tests to use the local model layer and added
focused routing coverage.
- Boxed a few large async TUI test futures so this base layer remains
checkable without overflowing the default test stack.

## Verification

- `cargo check -p codex-tui --tests`
This commit is contained in:
Eric Traut
2026-04-30 10:52:19 -07:00
committed by GitHub
Unverified
parent 487716ae74
commit c70cdc108f
11 changed files with 1294 additions and 132 deletions
@@ -0,0 +1,218 @@
//! Thread targeting helpers for app-server requests and notifications.
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
use codex_protocol::ThreadId;
pub(super) fn server_request_thread_id(request: &ServerRequest) -> Option<ThreadId> {
match request {
ServerRequest::CommandExecutionRequestApproval { params, .. } => {
ThreadId::from_string(&params.thread_id).ok()
}
ServerRequest::FileChangeRequestApproval { params, .. } => {
ThreadId::from_string(&params.thread_id).ok()
}
ServerRequest::ToolRequestUserInput { params, .. } => {
ThreadId::from_string(&params.thread_id).ok()
}
ServerRequest::McpServerElicitationRequest { params, .. } => {
ThreadId::from_string(&params.thread_id).ok()
}
ServerRequest::PermissionsRequestApproval { params, .. } => {
ThreadId::from_string(&params.thread_id).ok()
}
ServerRequest::DynamicToolCall { params, .. } => {
ThreadId::from_string(&params.thread_id).ok()
}
ServerRequest::ChatgptAuthTokensRefresh { .. }
| ServerRequest::ApplyPatchApproval { .. }
| ServerRequest::ExecCommandApproval { .. } => None,
}
}
#[derive(Debug, PartialEq, Eq)]
pub(super) enum ServerNotificationThreadTarget {
Thread(ThreadId),
InvalidThreadId(String),
Global,
}
pub(super) fn server_notification_thread_target(
notification: &ServerNotification,
) -> ServerNotificationThreadTarget {
let thread_id = match notification {
ServerNotification::Error(notification) => Some(notification.thread_id.as_str()),
ServerNotification::ThreadStarted(notification) => Some(notification.thread.id.as_str()),
ServerNotification::ThreadStatusChanged(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ThreadArchived(notification) => Some(notification.thread_id.as_str()),
ServerNotification::ThreadUnarchived(notification) => Some(notification.thread_id.as_str()),
ServerNotification::ThreadClosed(notification) => Some(notification.thread_id.as_str()),
ServerNotification::ThreadNameUpdated(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ThreadTokenUsageUpdated(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ThreadGoalUpdated(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ThreadGoalCleared(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::TurnStarted(notification) => Some(notification.thread_id.as_str()),
ServerNotification::HookStarted(notification) => Some(notification.thread_id.as_str()),
ServerNotification::TurnCompleted(notification) => Some(notification.thread_id.as_str()),
ServerNotification::HookCompleted(notification) => Some(notification.thread_id.as_str()),
ServerNotification::TurnDiffUpdated(notification) => Some(notification.thread_id.as_str()),
ServerNotification::TurnPlanUpdated(notification) => Some(notification.thread_id.as_str()),
ServerNotification::ItemStarted(notification) => Some(notification.thread_id.as_str()),
ServerNotification::ItemGuardianApprovalReviewStarted(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ItemGuardianApprovalReviewCompleted(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ItemCompleted(notification) => Some(notification.thread_id.as_str()),
ServerNotification::RawResponseItemCompleted(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::AgentMessageDelta(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::PlanDelta(notification) => Some(notification.thread_id.as_str()),
ServerNotification::CommandExecutionOutputDelta(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::TerminalInteraction(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::FileChangeOutputDelta(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::FileChangePatchUpdated(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ServerRequestResolved(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::McpToolCallProgress(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ReasoningSummaryTextDelta(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ReasoningSummaryPartAdded(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ReasoningTextDelta(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ContextCompacted(notification) => Some(notification.thread_id.as_str()),
ServerNotification::ModelRerouted(notification) => Some(notification.thread_id.as_str()),
ServerNotification::ModelVerification(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ThreadRealtimeStarted(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ThreadRealtimeItemAdded(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ThreadRealtimeTranscriptDelta(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ThreadRealtimeTranscriptDone(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ThreadRealtimeOutputAudioDelta(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ThreadRealtimeSdp(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ThreadRealtimeError(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ThreadRealtimeClosed(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::Warning(notification) => notification.thread_id.as_deref(),
ServerNotification::GuardianWarning(notification) => Some(notification.thread_id.as_str()),
ServerNotification::SkillsChanged(_)
| ServerNotification::McpServerStatusUpdated(_)
| ServerNotification::McpServerOauthLoginCompleted(_)
| ServerNotification::AccountUpdated(_)
| ServerNotification::AccountRateLimitsUpdated(_)
| ServerNotification::AppListUpdated(_)
| ServerNotification::RemoteControlStatusChanged(_)
| ServerNotification::ExternalAgentConfigImportCompleted(_)
| ServerNotification::DeprecationNotice(_)
| ServerNotification::ConfigWarning(_)
| ServerNotification::FuzzyFileSearchSessionUpdated(_)
| ServerNotification::FuzzyFileSearchSessionCompleted(_)
| ServerNotification::CommandExecOutputDelta(_)
| ServerNotification::FsChanged(_)
| ServerNotification::WindowsWorldWritableWarning(_)
| ServerNotification::WindowsSandboxSetupCompleted(_)
| ServerNotification::AccountLoginCompleted(_) => None,
};
match thread_id {
Some(thread_id) => match ThreadId::from_string(thread_id) {
Ok(thread_id) => ServerNotificationThreadTarget::Thread(thread_id),
Err(_) => ServerNotificationThreadTarget::InvalidThreadId(thread_id.to_string()),
},
None => ServerNotificationThreadTarget::Global,
}
}
#[cfg(test)]
mod tests {
use super::ServerNotificationThreadTarget;
use super::server_notification_thread_target;
use codex_app_server_protocol::GuardianWarningNotification;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::WarningNotification;
use codex_protocol::ThreadId;
use pretty_assertions::assert_eq;
#[test]
fn warning_notifications_without_threads_are_global() {
let notification = ServerNotification::Warning(WarningNotification {
thread_id: None,
message: "warning".to_string(),
});
let target = server_notification_thread_target(&notification);
assert_eq!(target, ServerNotificationThreadTarget::Global);
}
#[test]
fn warning_notifications_route_to_threads_when_thread_id_is_present() {
let thread_id = ThreadId::new();
let notification = ServerNotification::Warning(WarningNotification {
thread_id: Some(thread_id.to_string()),
message: "warning".to_string(),
});
let target = server_notification_thread_target(&notification);
assert_eq!(target, ServerNotificationThreadTarget::Thread(thread_id));
}
#[test]
fn guardian_warning_notifications_route_to_threads() {
let thread_id = ThreadId::new();
let notification = ServerNotification::GuardianWarning(GuardianWarningNotification {
thread_id: thread_id.to_string(),
message: "warning".to_string(),
});
let target = server_notification_thread_target(&notification);
assert_eq!(target, ServerNotificationThreadTarget::Thread(thread_id));
}
}
+208
View File
@@ -0,0 +1,208 @@
//! App-server event stream handling for the TUI app.
use super::App;
use super::app_server_event_targets::ServerNotificationThreadTarget;
use super::app_server_event_targets::server_notification_thread_target;
use super::app_server_event_targets::server_request_thread_id;
use crate::app_command::AppCommand;
use crate::app_event::AppEvent;
use crate::app_server_session::AppServerSession;
use crate::app_server_session::app_server_rate_limit_snapshot_to_core;
use crate::app_server_session::status_account_display_from_auth_mode;
use codex_app_server_client::AppServerEvent;
use codex_app_server_protocol::AuthMode;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
impl App {
fn refresh_mcp_startup_expected_servers_from_config(&mut self) {
let enabled_config_mcp_servers: Vec<String> = self
.chat_widget
.config_ref()
.mcp_servers
.get()
.iter()
.filter_map(|(name, server)| server.enabled.then_some(name.clone()))
.collect();
self.chat_widget
.set_mcp_startup_expected_servers(enabled_config_mcp_servers);
}
pub(super) async fn handle_app_server_event(
&mut self,
app_server_client: &AppServerSession,
event: AppServerEvent,
) {
match event {
AppServerEvent::Lagged { skipped } => {
tracing::warn!(
skipped,
"app-server event consumer lagged; dropping ignored events"
);
self.refresh_mcp_startup_expected_servers_from_config();
self.chat_widget.finish_mcp_startup_after_lag();
}
AppServerEvent::ServerNotification(notification) => {
self.handle_server_notification_event(app_server_client, notification)
.await;
}
AppServerEvent::ServerRequest(request) => {
self.handle_server_request_event(app_server_client, request)
.await;
}
AppServerEvent::Disconnected { message } => {
tracing::warn!("app-server event stream disconnected: {message}");
self.chat_widget.add_error_message(message.clone());
self.app_event_tx.send(AppEvent::FatalExitRequest(message));
}
}
}
async fn handle_server_notification_event(
&mut self,
app_server_client: &AppServerSession,
notification: ServerNotification,
) {
match &notification {
ServerNotification::ServerRequestResolved(notification) => {
if let Some(request) = self
.pending_app_server_requests
.resolve_notification(&notification.request_id)
{
self.chat_widget.dismiss_app_server_request(&request);
}
}
ServerNotification::McpServerStatusUpdated(_) => {
self.refresh_mcp_startup_expected_servers_from_config();
}
ServerNotification::AccountRateLimitsUpdated(notification) => {
self.chat_widget.on_rate_limit_snapshot(Some(
app_server_rate_limit_snapshot_to_core(notification.rate_limits.clone()),
));
return;
}
ServerNotification::AccountUpdated(notification) => {
self.chat_widget.update_account_state(
status_account_display_from_auth_mode(
notification.auth_mode,
notification.plan_type,
),
notification.plan_type,
matches!(
notification.auth_mode,
Some(AuthMode::Chatgpt) | Some(AuthMode::ChatgptAuthTokens)
),
);
return;
}
ServerNotification::ExternalAgentConfigImportCompleted(_) => {
let cwd = self.chat_widget.config_ref().cwd.to_path_buf();
if let Err(err) = self.refresh_in_memory_config_from_disk().await {
tracing::warn!(
error = %err,
"failed to refresh config after external agent config import"
);
}
self.chat_widget.refresh_plugin_mentions();
self.chat_widget.submit_op(AppCommand::reload_user_config());
self.fetch_plugins_list(app_server_client, cwd);
return;
}
_ => {}
}
match server_notification_thread_target(&notification) {
ServerNotificationThreadTarget::Thread(thread_id) => {
let result = if self.primary_thread_id == Some(thread_id)
|| self.primary_thread_id.is_none()
{
self.enqueue_primary_thread_notification(notification).await
} else {
self.enqueue_thread_notification(thread_id, notification)
.await
};
if let Err(err) = result {
tracing::warn!("failed to enqueue app-server notification: {err}");
}
return;
}
ServerNotificationThreadTarget::InvalidThreadId(thread_id) => {
tracing::warn!(
thread_id,
"ignoring app-server notification with invalid thread_id"
);
return;
}
ServerNotificationThreadTarget::Global => {}
}
self.chat_widget
.handle_server_notification(notification, /*replay_kind*/ None);
}
async fn handle_server_request_event(
&mut self,
app_server_client: &AppServerSession,
request: ServerRequest,
) {
if let Some(unsupported) = self
.pending_app_server_requests
.note_server_request(&request)
{
tracing::warn!(
request_id = ?unsupported.request_id,
message = unsupported.message,
"rejecting unsupported app-server request"
);
self.chat_widget
.add_error_message(unsupported.message.clone());
if let Err(err) = self
.reject_app_server_request(
app_server_client,
unsupported.request_id,
unsupported.message,
)
.await
{
tracing::warn!("{err}");
}
return;
}
let Some(thread_id) = server_request_thread_id(&request) else {
tracing::warn!("ignoring threadless app-server request");
return;
};
let result =
if self.primary_thread_id == Some(thread_id) || self.primary_thread_id.is_none() {
self.enqueue_primary_thread_request(request).await
} else {
self.enqueue_thread_request(thread_id, request).await
};
if let Err(err) = result {
tracing::warn!("failed to enqueue app-server request: {err}");
}
}
async fn reject_app_server_request(
&self,
app_server_client: &AppServerSession,
request_id: codex_app_server_protocol::RequestId,
reason: String,
) -> std::result::Result<(), String> {
app_server_client
.reject_server_request(
request_id,
JSONRPCErrorError {
code: -32000,
message: reason,
data: None,
},
)
.await
.map_err(|err| format!("failed to reject app-server request: {err}"))
}
}
+172 -131
View File
@@ -1227,15 +1227,17 @@ async fn token_usage_update_refreshes_status_line_with_runtime_context_window()
#[tokio::test]
async fn open_agent_picker_keeps_missing_threads_for_replay() -> Result<()> {
let mut app = make_test_app().await;
let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let mut app = Box::pin(make_test_app()).await;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
app.chat_widget.config_ref(),
))
.await
.expect("embedded app server");
let thread_id = ThreadId::new();
app.thread_event_channels
.insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1));
app.open_agent_picker(&mut app_server).await;
Box::pin(app.open_agent_picker(&mut app_server)).await;
assert_eq!(app.thread_event_channels.contains_key(&thread_id), true);
assert_eq!(
@@ -1252,10 +1254,12 @@ async fn open_agent_picker_keeps_missing_threads_for_replay() -> Result<()> {
#[tokio::test]
async fn open_agent_picker_preserves_cached_metadata_for_replay_threads() -> Result<()> {
let mut app = make_test_app().await;
let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let mut app = Box::pin(make_test_app()).await;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
app.chat_widget.config_ref(),
))
.await
.expect("embedded app server");
let thread_id = ThreadId::new();
app.thread_event_channels
.insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1));
@@ -1266,7 +1270,7 @@ async fn open_agent_picker_preserves_cached_metadata_for_replay_threads() -> Res
/*is_closed*/ true,
);
app.open_agent_picker(&mut app_server).await;
Box::pin(app.open_agent_picker(&mut app_server)).await;
assert_eq!(app.thread_event_channels.contains_key(&thread_id), true);
assert_eq!(
@@ -1282,10 +1286,12 @@ async fn open_agent_picker_preserves_cached_metadata_for_replay_threads() -> Res
#[tokio::test]
async fn open_agent_picker_prunes_terminal_metadata_only_threads() -> Result<()> {
let mut app = make_test_app().await;
let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let mut app = Box::pin(make_test_app()).await;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
app.chat_widget.config_ref(),
))
.await
.expect("embedded app server");
let thread_id = ThreadId::new();
app.agent_navigation.upsert(
thread_id,
@@ -1294,7 +1300,7 @@ async fn open_agent_picker_prunes_terminal_metadata_only_threads() -> Result<()>
/*is_closed*/ false,
);
app.open_agent_picker(&mut app_server).await;
Box::pin(app.open_agent_picker(&mut app_server)).await;
assert_eq!(app.agent_navigation.get(&thread_id), None);
assert!(app.agent_navigation.is_empty());
@@ -1303,10 +1309,12 @@ async fn open_agent_picker_prunes_terminal_metadata_only_threads() -> Result<()>
#[tokio::test]
async fn open_agent_picker_marks_terminal_read_errors_closed() -> Result<()> {
let mut app = make_test_app().await;
let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let mut app = Box::pin(make_test_app()).await;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
app.chat_widget.config_ref(),
))
.await
.expect("embedded app server");
let thread_id = ThreadId::new();
app.thread_event_channels
.insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1));
@@ -1317,7 +1325,7 @@ async fn open_agent_picker_marks_terminal_read_errors_closed() -> Result<()> {
/*is_closed*/ false,
);
app.open_agent_picker(&mut app_server).await;
Box::pin(app.open_agent_picker(&mut app_server)).await;
assert_eq!(
app.agent_navigation.get(&thread_id),
@@ -1332,10 +1340,12 @@ async fn open_agent_picker_marks_terminal_read_errors_closed() -> Result<()> {
#[tokio::test]
async fn open_agent_picker_marks_loaded_threads_open() -> Result<()> {
let mut app = make_test_app().await;
let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let mut app = Box::pin(make_test_app()).await;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
app.chat_widget.config_ref(),
))
.await
.expect("embedded app server");
let started = app_server
.start_thread(app.chat_widget.config_ref())
.await?;
@@ -1343,7 +1353,7 @@ async fn open_agent_picker_marks_loaded_threads_open() -> Result<()> {
app.thread_event_channels
.insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1));
app.open_agent_picker(&mut app_server).await;
Box::pin(app.open_agent_picker(&mut app_server)).await;
assert_eq!(
app.agent_navigation.get(&thread_id),
@@ -1356,65 +1366,87 @@ async fn open_agent_picker_marks_loaded_threads_open() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn attach_live_thread_for_selection_rejects_empty_non_ephemeral_fallback_threads()
-> Result<()> {
let mut app = make_test_app().await;
let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let started = app_server
.start_thread(app.chat_widget.config_ref())
.await?;
let thread_id = started.session.thread_id;
app.agent_navigation.upsert(
thread_id,
Some("Scout".to_string()),
Some("worker".to_string()),
/*is_closed*/ false,
);
#[test]
fn attach_live_thread_for_selection_rejects_empty_non_ephemeral_fallback_threads() -> Result<()> {
const WORKER_THREADS: usize = 1;
const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024;
let err = app
.attach_live_thread_for_selection(&mut app_server, thread_id)
.await
.expect_err("empty fallback should not attach as a blank replay-only thread");
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(WORKER_THREADS)
.thread_stack_size(TEST_STACK_SIZE_BYTES)
.enable_all()
.build()?;
assert_eq!(
err.to_string(),
format!("Agent thread {thread_id} is not yet available for replay or live attach.")
);
assert!(!app.thread_event_channels.contains_key(&thread_id));
Ok(())
runtime.block_on(async {
let config = {
let app = make_test_app().await;
app.chat_widget.config_ref().clone()
};
let mut app_server = crate::start_embedded_app_server_for_picker(&config)
.await
.expect("embedded app server");
let started = app_server.start_thread(&config).await?;
let thread_id = started.session.thread_id;
let mut app = make_test_app().await;
app.agent_navigation.upsert(
thread_id,
Some("Scout".to_string()),
Some("worker".to_string()),
/*is_closed*/ false,
);
let err = app
.attach_live_thread_for_selection(&mut app_server, thread_id)
.await
.expect_err("empty fallback should not attach as a blank replay-only thread");
assert_eq!(
err.to_string(),
format!("Agent thread {thread_id} is not yet available for replay or live attach.")
);
assert!(!app.thread_event_channels.contains_key(&thread_id));
Ok(())
})
}
#[tokio::test]
async fn attach_live_thread_for_selection_rejects_unmaterialized_fallback_threads() -> Result<()> {
let mut app = make_test_app().await;
let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let mut ephemeral_config = app.chat_widget.config_ref().clone();
ephemeral_config.ephemeral = true;
let started = app_server.start_thread(&ephemeral_config).await?;
let thread_id = started.session.thread_id;
app.agent_navigation.upsert(
thread_id,
Some("Scout".to_string()),
Some("worker".to_string()),
/*is_closed*/ false,
);
#[test]
fn attach_live_thread_for_selection_rejects_unmaterialized_fallback_threads() -> Result<()> {
const WORKER_THREADS: usize = 1;
const TEST_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024;
let err = app
.attach_live_thread_for_selection(&mut app_server, thread_id)
.await
.expect_err("ephemeral fallback should not attach as a blank live thread");
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(WORKER_THREADS)
.thread_stack_size(TEST_STACK_SIZE_BYTES)
.enable_all()
.build()?;
assert_eq!(
err.to_string(),
format!("Agent thread {thread_id} is not yet available for replay or live attach.")
);
assert!(!app.thread_event_channels.contains_key(&thread_id));
Ok(())
runtime.block_on(async {
let mut app = make_test_app().await;
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
let mut ephemeral_config = app.chat_widget.config_ref().clone();
ephemeral_config.ephemeral = true;
let started = app_server.start_thread(&ephemeral_config).await?;
let thread_id = started.session.thread_id;
app.agent_navigation.upsert(
thread_id,
Some("Scout".to_string()),
Some("worker".to_string()),
/*is_closed*/ false,
);
let err = app
.attach_live_thread_for_selection(&mut app_server, thread_id)
.await
.expect_err("ephemeral fallback should not attach as a blank live thread");
assert_eq!(
err.to_string(),
format!("Agent thread {thread_id} is not yet available for replay or live attach.")
);
assert!(!app.thread_event_channels.contains_key(&thread_id));
Ok(())
})
}
#[tokio::test]
@@ -1445,10 +1477,12 @@ async fn should_attach_live_thread_for_selection_skips_closed_metadata_only_thre
#[tokio::test]
async fn refresh_agent_picker_thread_liveness_prunes_closed_metadata_only_threads() -> Result<()> {
let mut app = make_test_app().await;
let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let mut app = Box::pin(make_test_app()).await;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
app.chat_widget.config_ref(),
))
.await
.expect("embedded app server");
let thread_id = ThreadId::new();
app.agent_navigation.upsert(
thread_id,
@@ -1457,9 +1491,8 @@ async fn refresh_agent_picker_thread_liveness_prunes_closed_metadata_only_thread
/*is_closed*/ false,
);
let is_available = app
.refresh_agent_picker_thread_liveness(&mut app_server, thread_id)
.await;
let is_available =
Box::pin(app.refresh_agent_picker_thread_liveness(&mut app_server, thread_id)).await;
assert!(!is_available);
assert_eq!(app.agent_navigation.get(&thread_id), None);
@@ -1469,13 +1502,15 @@ async fn refresh_agent_picker_thread_liveness_prunes_closed_metadata_only_thread
#[tokio::test]
async fn open_agent_picker_prompts_to_enable_multi_agent_when_disabled() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let (mut app, mut app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
app.chat_widget.config_ref(),
))
.await
.expect("embedded app server");
let _ = app.config.features.disable(Feature::Collab);
app.open_agent_picker(&mut app_server).await;
Box::pin(app.open_agent_picker(&mut app_server)).await;
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
@@ -1499,16 +1534,16 @@ async fn open_agent_picker_prompts_to_enable_multi_agent_when_disabled() -> Resu
#[tokio::test]
async fn update_memory_settings_persists_and_updates_widget_config() -> Result<()> {
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
let (mut app, _app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await;
let codex_home = tempdir()?;
app.config.codex_home = codex_home.path().to_path_buf().abs();
let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
app.update_memory_settings_with_app_server(
Box::pin(app.update_memory_settings_with_app_server(
&mut app_server,
/*use_memories*/ false,
/*generate_memories*/ false,
)
))
.await;
assert!(!app.config.memories.use_memories);
@@ -1542,22 +1577,22 @@ async fn update_memory_settings_persists_and_updates_widget_config() -> Result<(
#[tokio::test]
async fn update_memory_settings_updates_current_thread_memory_mode() -> Result<()> {
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
let (mut app, _app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await;
let codex_home = tempdir()?;
app.config.codex_home = codex_home.path().to_path_buf().abs();
// Seed the previous setting so this test exercises the thread-mode update path.
app.config.memories.generate_memories = true;
let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
let started = app_server.start_thread(&app.config).await?;
let thread_id = started.session.thread_id;
app.active_thread_id = Some(thread_id);
app.update_memory_settings_with_app_server(
Box::pin(app.update_memory_settings_with_app_server(
&mut app_server,
/*use_memories*/ true,
/*generate_memories*/ false,
)
))
.await;
let state_db = codex_state::StateRuntime::init(
@@ -1578,7 +1613,7 @@ async fn update_memory_settings_updates_current_thread_memory_mode() -> Result<(
#[tokio::test]
async fn reset_memories_clears_local_memory_directories() -> Result<()> {
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
let (mut app, _app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await;
let codex_home = tempdir()?;
app.config.codex_home = codex_home.path().to_path_buf().abs();
app.config.sqlite_home = codex_home.path().to_path_buf();
@@ -1594,9 +1629,9 @@ async fn reset_memories_clears_local_memory_directories() -> Result<()> {
)?;
std::fs::write(extensions_root.join("stale.txt"), "stale extension\n")?;
let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
app.reset_memories_with_app_server(&mut app_server).await;
Box::pin(app.reset_memories_with_app_server(&mut app_server)).await;
assert_eq!(std::fs::read_dir(&memory_root)?.count(), 0);
@@ -2138,15 +2173,17 @@ async fn update_feature_flags_disabling_guardian_in_profile_keeps_inherited_non_
#[tokio::test]
async fn open_agent_picker_allows_existing_agent_threads_when_feature_is_disabled() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let (mut app, mut app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
app.chat_widget.config_ref(),
))
.await
.expect("embedded app server");
let thread_id = ThreadId::new();
app.thread_event_channels
.insert(thread_id, ThreadEventChannel::new(/*capacity*/ 1));
app.open_agent_picker(&mut app_server).await;
Box::pin(app.open_agent_picker(&mut app_server)).await;
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
@@ -4856,7 +4893,7 @@ async fn thread_rollback_response_discards_queued_active_thread_events() {
#[tokio::test]
async fn new_session_requests_shutdown_for_previous_conversation() {
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
let (mut app, mut app_event_rx, mut op_rx) = Box::pin(make_test_app_with_channels()).await;
let thread_id = ThreadId::new();
let event = SessionConfiguredEvent {
@@ -4887,10 +4924,12 @@ async fn new_session_requests_shutdown_for_previous_conversation() {
while app_event_rx.try_recv().is_ok() {}
while op_rx.try_recv().is_ok() {}
let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
app.shutdown_current_thread(&mut app_server).await;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
app.chat_widget.config_ref(),
))
.await
.expect("embedded app server");
Box::pin(app.shutdown_current_thread(&mut app_server)).await;
assert!(
op_rx.try_recv().is_err(),
@@ -4904,12 +4943,12 @@ async fn shutdown_first_exit_returns_immediate_exit_when_shutdown_submit_fails()
let thread_id = ThreadId::new();
app.active_thread_id = Some(thread_id);
let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let control = app
.handle_exit_mode(&mut app_server, ExitMode::ShutdownFirst)
.await;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
app.chat_widget.config_ref(),
))
.await
.expect("embedded app server");
let control = Box::pin(app.handle_exit_mode(&mut app_server, ExitMode::ShutdownFirst)).await;
assert_eq!(app.pending_shutdown_exit_thread_id, None);
assert!(matches!(
@@ -4920,16 +4959,16 @@ async fn shutdown_first_exit_returns_immediate_exit_when_shutdown_submit_fails()
#[tokio::test]
async fn shutdown_first_exit_uses_app_server_shutdown_without_submitting_op() {
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
let (mut app, _app_event_rx, mut op_rx) = Box::pin(make_test_app_with_channels()).await;
let thread_id = ThreadId::new();
app.active_thread_id = Some(thread_id);
let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let control = app
.handle_exit_mode(&mut app_server, ExitMode::ShutdownFirst)
.await;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
app.chat_widget.config_ref(),
))
.await
.expect("embedded app server");
let control = Box::pin(app.handle_exit_mode(&mut app_server, ExitMode::ShutdownFirst)).await;
assert_eq!(app.pending_shutdown_exit_thread_id, None);
assert!(matches!(
@@ -4945,9 +4984,11 @@ async fn shutdown_first_exit_uses_app_server_shutdown_without_submitting_op() {
#[tokio::test]
async fn interrupt_without_active_turn_is_treated_as_handled() {
let mut app = make_test_app().await;
let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
app.chat_widget.config_ref(),
))
.await
.expect("embedded app server");
let started = app_server
.start_thread(app.chat_widget.config_ref())
.await
@@ -4958,10 +4999,10 @@ async fn interrupt_without_active_turn_is_treated_as_handled() {
.expect("primary thread should be registered");
let op = AppCommand::interrupt();
let handled = app
.try_submit_active_thread_op_via_app_server(&mut app_server, thread_id, &op)
.await
.expect("interrupt submission should not fail");
let handled =
Box::pin(app.try_submit_active_thread_op_via_app_server(&mut app_server, thread_id, &op))
.await
.expect("interrupt submission should not fail");
assert_eq!(handled, true);
}