Compare commits

...

3 Commits

Author SHA1 Message Date
jif-oai
b94a994400 nits 2026-04-07 09:20:14 +01:00
jif-oai
4d9ac9179e make it better 2026-04-02 18:42:44 +02:00
jif-oai
25fefe8735 feat: single app-server bootstrap in TUI 2026-04-02 18:18:15 +02:00
3 changed files with 158 additions and 88 deletions

View File

@@ -89,7 +89,6 @@ use std::collections::HashMap;
use std::path::PathBuf;
pub(crate) struct AppServerBootstrap {
pub(crate) account_auth_mode: Option<AuthMode>,
pub(crate) account_email: Option<String>,
pub(crate) auth_mode: Option<TelemetryAuthMode>,
pub(crate) status_account_display: Option<StatusAccountDisplay>,
@@ -157,10 +156,9 @@ impl AppServerSession {
matches!(self.client, AppServerClient::Remote(_))
}
pub(crate) async fn bootstrap(&mut self, config: &Config) -> Result<AppServerBootstrap> {
pub(crate) async fn account(&mut self) -> Result<GetAccountResponse> {
let account_request_id = self.next_request_id();
let account: GetAccountResponse = self
.client
self.client
.request_typed(ClientRequest::GetAccount {
request_id: account_request_id,
params: GetAccountParams {
@@ -168,7 +166,11 @@ impl AppServerSession {
},
})
.await
.wrap_err("account/read failed during TUI bootstrap")?;
.wrap_err("account/read failed during TUI startup")
}
pub(crate) async fn bootstrap(&mut self, config: &Config) -> Result<AppServerBootstrap> {
let account = self.account().await?;
let model_request_id = self.next_request_id();
let models: ModelListResponse = self
.client
@@ -200,7 +202,6 @@ impl AppServerSession {
.wrap_err("model/list returned no models for TUI bootstrap")?;
let (
account_auth_mode,
account_email,
auth_mode,
status_account_display,
@@ -209,7 +210,6 @@ impl AppServerSession {
has_chatgpt_account,
) = match account.account {
Some(Account::ApiKey {}) => (
Some(AuthMode::ApiKey),
None,
Some(TelemetryAuthMode::ApiKey),
Some(StatusAccountDisplay::ApiKey),
@@ -224,7 +224,6 @@ impl AppServerSession {
FeedbackAudience::External
};
(
Some(AuthMode::Chatgpt),
Some(email.clone()),
Some(TelemetryAuthMode::Chatgpt),
Some(StatusAccountDisplay::ChatGpt {
@@ -241,7 +240,6 @@ impl AppServerSession {
None,
None,
None,
None,
FeedbackAudience::External,
false,
),
@@ -267,7 +265,6 @@ impl AppServerSession {
};
Ok(AppServerBootstrap {
account_auth_mode,
account_email,
auth_mode,
status_account_display,

View File

@@ -14,6 +14,7 @@ use codex_app_server_client::InProcessAppServerClient;
use codex_app_server_client::InProcessClientStartArgs;
use codex_app_server_client::RemoteAppServerClient;
use codex_app_server_client::RemoteAppServerConnectArgs;
use codex_app_server_protocol::Account as AppServerAccount;
use codex_app_server_protocol::AuthMode as AppServerAuthMode;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::Thread as AppServerThread;
@@ -392,6 +393,39 @@ pub(crate) async fn start_app_server_for_picker(
Ok(AppServerSession::new(app_server))
}
#[allow(clippy::too_many_arguments)]
async fn prepare_app_server_for_final_config(
app_server: Option<AppServerSession>,
config_was_reloaded: bool,
target: &AppServerTarget,
arg0_paths: Arg0DispatchPaths,
config: Config,
cli_kv_overrides: Vec<(String, toml::Value)>,
loader_overrides: LoaderOverrides,
cloud_requirements: CloudRequirementsLoader,
feedback: codex_feedback::CodexFeedback,
) -> color_eyre::Result<AppServerSession> {
match app_server {
Some(app_server) if !config_was_reloaded => Ok(app_server),
stale_app_server => {
if let Some(stale_app_server) = stale_app_server {
shutdown_app_server(stale_app_server).await;
}
start_app_server(
target,
arg0_paths,
config,
cli_kv_overrides,
loader_overrides,
cloud_requirements,
feedback,
)
.await
.map(AppServerSession::new)
}
}
}
#[cfg(test)]
pub(crate) async fn start_embedded_app_server_for_picker(
config: &Config,
@@ -443,11 +477,9 @@ where
Ok(client)
}
async fn shutdown_app_server_if_present(app_server: Option<AppServerSession>) {
if let Some(app_server) = app_server
&& let Err(err) = app_server.shutdown().await
{
warn!(%err, "Failed to shut down temporary embedded app server");
async fn shutdown_app_server(app_server: AppServerSession) {
if let Err(err) = app_server.shutdown().await {
warn!(%err, "Failed to shut down temporary app server");
}
}
@@ -979,29 +1011,32 @@ async fn run_ratatui_app(
// Initialize high-fidelity session event logging if enabled.
session_log::maybe_init(&initial_config);
let mut app_server = Some(
match start_app_server(
&app_server_target,
arg0_paths.clone(),
initial_config.clone(),
cli_kv_overrides.clone(),
loader_overrides.clone(),
cloud_requirements.clone(),
feedback.clone(),
)
.await
{
Ok(app_server) => AppServerSession::new(app_server),
Err(err) => {
terminal_restore_guard.restore_silently();
session_log::log_session_end();
return Err(err);
}
},
);
let should_show_trust_screen_flag = !remote_mode && should_show_trust_screen(&initial_config);
let mut trust_decision_was_made = false;
let needs_onboarding_app_server =
should_show_trust_screen_flag || initial_config.model_provider.requires_openai_auth;
let mut onboarding_app_server = if needs_onboarding_app_server {
Some(AppServerSession::new(
start_app_server(
&app_server_target,
arg0_paths.clone(),
initial_config.clone(),
cli_kv_overrides.clone(),
loader_overrides.clone(),
cloud_requirements.clone(),
feedback.clone(),
)
.await?,
))
} else {
None
};
let login_status = if initial_config.model_provider.requires_openai_auth {
let Some(app_server) = onboarding_app_server.as_mut() else {
unreachable!("onboarding app server should exist when auth is required");
let Some(app_server) = app_server.as_mut() else {
unreachable!("app server should exist when auth is required");
};
get_login_status(app_server, &initial_config).await?
} else {
@@ -1010,6 +1045,7 @@ async fn run_ratatui_app(
let should_show_onboarding =
should_show_onboarding(login_status, &initial_config, should_show_trust_screen_flag);
let mut config_was_reloaded = false;
let config = if should_show_onboarding {
let show_login_screen = should_show_login_screen(login_status, &initial_config);
let onboarding_result = run_onboarding_app(
@@ -1017,13 +1053,13 @@ async fn run_ratatui_app(
show_login_screen,
show_trust_screen: should_show_trust_screen_flag,
login_status,
app_server_request_handle: onboarding_app_server
app_server_request_handle: app_server
.as_ref()
.map(AppServerSession::request_handle),
config: initial_config.clone(),
},
if show_login_screen {
onboarding_app_server.take()
app_server.as_mut()
} else {
None
},
@@ -1031,6 +1067,9 @@ async fn run_ratatui_app(
)
.await?;
if onboarding_result.should_exit {
if let Some(app_server) = app_server.take() {
shutdown_app_server(app_server).await;
}
terminal_restore_guard.restore_silently();
session_log::log_session_end();
let _ = tui.terminal.clear();
@@ -1060,6 +1099,7 @@ async fn run_ratatui_app(
if onboarding_result.directory_trust_decision.is_some()
|| (show_login_screen && !remote_mode)
{
config_was_reloaded = true;
load_config_or_exit(
cli_kv_overrides.clone(),
overrides.clone(),
@@ -1070,10 +1110,8 @@ async fn run_ratatui_app(
initial_config
}
} else {
shutdown_app_server_if_present(onboarding_app_server.take()).await;
initial_config
};
shutdown_app_server_if_present(onboarding_app_server.take()).await;
let mut missing_session_exit = |id_str: &str, action: &str| {
error!("Error finding conversation path: {id_str}");
@@ -1091,45 +1129,24 @@ async fn run_ratatui_app(
})
};
let needs_app_server_session_lookup = cli.resume_last
|| cli.fork_last
|| cli.resume_session_id.is_some()
|| cli.fork_session_id.is_some()
|| cli.resume_picker
|| cli.fork_picker;
let mut session_lookup_app_server = if needs_app_server_session_lookup {
Some(AppServerSession::new(
start_app_server(
&app_server_target,
arg0_paths.clone(),
config.clone(),
cli_kv_overrides.clone(),
loader_overrides.clone(),
cloud_requirements.clone(),
feedback.clone(),
)
.await?,
))
} else {
None
};
let use_fork = cli.fork_picker || cli.fork_last || cli.fork_session_id.is_some();
let session_selection = if use_fork {
if let Some(id_str) = cli.fork_session_id.as_deref() {
let Some(app_server) = session_lookup_app_server.as_mut() else {
unreachable!("session lookup app server should be initialized for --fork <id>");
let Some(startup_app_server) = app_server.as_mut() else {
unreachable!("app server should be initialized for --fork <id>");
};
match lookup_session_target_with_app_server(app_server, id_str).await? {
match lookup_session_target_with_app_server(startup_app_server, id_str).await? {
Some(target_session) => resume_picker::SessionSelection::Fork(target_session),
None => {
shutdown_app_server_if_present(session_lookup_app_server.take()).await;
if let Some(app_server) = app_server.take() {
shutdown_app_server(app_server).await;
}
return missing_session_exit(id_str, "fork");
}
}
} else if cli.fork_last {
let Some(app_server) = session_lookup_app_server.as_mut() else {
unreachable!("session lookup app server should be initialized for --fork --last");
let Some(app_server) = app_server.as_mut() else {
unreachable!("app server should be initialized for --fork --last");
};
match lookup_latest_session_target_with_app_server(
app_server, &config, /*cwd_filter*/ None,
@@ -1141,8 +1158,8 @@ async fn run_ratatui_app(
None => resume_picker::SessionSelection::StartFresh,
}
} else if cli.fork_picker {
let Some(app_server) = session_lookup_app_server.take() else {
unreachable!("session lookup app server should be initialized for --fork picker");
let Some(app_server) = app_server.take() else {
unreachable!("app server should be initialized for --fork picker");
};
match resume_picker::run_fork_picker_with_app_server(
&mut tui,
@@ -1169,13 +1186,15 @@ async fn run_ratatui_app(
resume_picker::SessionSelection::StartFresh
}
} else if let Some(id_str) = cli.resume_session_id.as_deref() {
let Some(app_server) = session_lookup_app_server.as_mut() else {
unreachable!("session lookup app server should be initialized for --resume <id>");
let Some(startup_app_server) = app_server.as_mut() else {
unreachable!("app server should be initialized for --resume <id>");
};
match lookup_session_target_with_app_server(app_server, id_str).await? {
match lookup_session_target_with_app_server(startup_app_server, id_str).await? {
Some(target_session) => resume_picker::SessionSelection::Resume(target_session),
None => {
shutdown_app_server_if_present(session_lookup_app_server.take()).await;
if let Some(app_server) = app_server.take() {
shutdown_app_server(app_server).await;
}
return missing_session_exit(id_str, "resume");
}
}
@@ -1185,8 +1204,8 @@ async fn run_ratatui_app(
} else {
Some(config.cwd.as_path())
};
let Some(app_server) = session_lookup_app_server.as_mut() else {
unreachable!("session lookup app server should be initialized for --resume --last");
let Some(app_server) = app_server.as_mut() else {
unreachable!("app server should be initialized for --resume --last");
};
match lookup_latest_session_target_with_app_server(
app_server,
@@ -1200,8 +1219,8 @@ async fn run_ratatui_app(
None => resume_picker::SessionSelection::StartFresh,
}
} else if cli.resume_picker {
let Some(app_server) = session_lookup_app_server.take() else {
unreachable!("session lookup app server should be initialized for --resume picker");
let Some(app_server) = app_server.take() else {
unreachable!("app server should be initialized for --resume picker");
};
match resume_picker::run_resume_picker_with_app_server(
&mut tui,
@@ -1228,7 +1247,6 @@ async fn run_ratatui_app(
} else {
resume_picker::SessionSelection::StartFresh
};
shutdown_app_server_if_present(session_lookup_app_server.take()).await;
let current_cwd = config.cwd.clone();
let allow_prompt = !remote_mode && cli.cwd.is_none();
@@ -1277,6 +1295,7 @@ async fn run_ratatui_app(
let mut config = match &session_selection {
resume_picker::SessionSelection::Resume(_) | resume_picker::SessionSelection::Fork(_) => {
config_was_reloaded = true;
load_config_or_exit_with_fallback_cwd(
cli_kv_overrides.clone(),
overrides.clone(),
@@ -1314,7 +1333,9 @@ async fn run_ratatui_app(
let use_alt_screen = determine_alt_screen_mode(no_alt_screen, config.tui_alternate_screen);
tui.set_alt_screen_enabled(use_alt_screen);
let app_server = match start_app_server(
let app_server = match prepare_app_server_for_final_config(
app_server,
config_was_reloaded,
&app_server_target,
arg0_paths,
config.clone(),
@@ -1335,7 +1356,7 @@ async fn run_ratatui_app(
let app_result = App::run(
&mut tui,
AppServerSession::new(app_server),
app_server,
config,
cli_kv_overrides.clone(),
overrides.clone(),
@@ -1570,9 +1591,10 @@ async fn get_login_status(
return Ok(LoginStatus::NotAuthenticated);
}
let bootstrap = app_server.bootstrap(config).await?;
Ok(match bootstrap.account_auth_mode {
Some(auth_mode) => LoginStatus::AuthMode(auth_mode),
let account = app_server.account().await?;
Ok(match account.account {
Some(AppServerAccount::ApiKey {}) => LoginStatus::AuthMode(AppServerAuthMode::ApiKey),
Some(AppServerAccount::Chatgpt { .. }) => LoginStatus::AuthMode(AppServerAuthMode::Chatgpt),
None => LoginStatus::NotAuthenticated,
})
}
@@ -1659,6 +1681,7 @@ mod tests {
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::TurnContextItem;
use pretty_assertions::assert_eq;
use serial_test::serial;
use tempfile::TempDir;
@@ -1858,6 +1881,59 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn prepare_app_server_restarts_with_final_config_warnings_after_reload()
-> color_eyre::Result<()> {
let temp_dir = TempDir::new()?;
let mut stale_config = build_config(&temp_dir).await?;
stale_config
.startup_warnings
.push("stale warning".to_string());
let stale_app_server =
AppServerSession::new(codex_app_server_client::AppServerClient::InProcess(
start_test_embedded_app_server(stale_config).await?,
));
let mut config = build_config(&temp_dir).await?;
config.startup_warnings.push("final warning".to_string());
let mut app_server = prepare_app_server_for_final_config(
Some(stale_app_server),
/*config_was_reloaded*/ true,
&AppServerTarget::Embedded,
Arg0DispatchPaths::default(),
config,
Vec::new(),
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
codex_feedback::CodexFeedback::new(),
)
.await?;
let event =
tokio::time::timeout(std::time::Duration::from_secs(2), app_server.next_event())
.await
.expect("config warning notification should arrive")
.expect("config warning notification should be emitted");
let codex_app_server_client::AppServerEvent::ServerNotification(
codex_app_server_protocol::ServerNotification::ConfigWarning(notification),
) = event
else {
panic!("expected config warning notification");
};
assert_eq!(
notification,
ConfigWarningNotification {
summary: "final warning".to_string(),
details: None,
path: None,
range: None,
}
);
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn lookup_session_target_by_name_ignores_backend_search_term_mismatch()
-> color_eyre::Result<()> {

View File

@@ -433,7 +433,7 @@ impl WidgetRef for Step {
pub(crate) async fn run_onboarding_app(
args: OnboardingScreenArgs,
mut app_server: Option<AppServerSession>,
mut app_server: Option<&mut AppServerSession>,
tui: &mut Tui,
) -> Result<OnboardingResult> {
use tokio_stream::StreamExt;
@@ -519,9 +519,6 @@ pub(crate) async fn run_onboarding_app(
}
}
}
if let Some(app_server) = app_server {
app_server.shutdown().await.ok();
}
Ok(OnboardingResult {
directory_trust_decision: onboarding_screen.directory_trust_decision(),
should_exit: onboarding_screen.should_exit(),