//! App-server session facade used by the TUI event loop. //! //! This module owns the typed JSON-RPC calls needed by the TUI and keeps //! request/response plumbing out of `App` and `ChatWidget`. use crate::bottom_pane::FeedbackAudience; use crate::legacy_core::config::Config; use crate::session_state::MessageHistoryMetadata; use crate::session_state::ThreadSessionState; use crate::status::StatusAccountDisplay; use crate::status::plan_type_display_name; use codex_app_server_client::AppServerClient; use codex_app_server_client::AppServerEvent; use codex_app_server_client::AppServerRequestHandle; use codex_app_server_client::TypedRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigWriteResponse; use codex_app_server_protocol::ExternalAgentConfigDetectParams; use codex_app_server_protocol::ExternalAgentConfigDetectResponse; use codex_app_server_protocol::ExternalAgentConfigImportParams; use codex_app_server_protocol::ExternalAgentConfigImportResponse; use codex_app_server_protocol::ExternalAgentConfigMigrationItem; use codex_app_server_protocol::GetAccountParams; use codex_app_server_protocol::GetAccountRateLimitsResponse; use codex_app_server_protocol::GetAccountResponse; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::LogoutAccountResponse; use codex_app_server_protocol::MemoryResetResponse; use codex_app_server_protocol::Model as ApiModel; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; use codex_app_server_protocol::PermissionProfileSelectionParams; use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ReviewDelivery; use codex_app_server_protocol::ReviewStartParams; use codex_app_server_protocol::ReviewStartResponse; use codex_app_server_protocol::ReviewTarget; use codex_app_server_protocol::SkillsListParams; use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadApproveGuardianDeniedActionParams; use codex_app_server_protocol::ThreadApproveGuardianDeniedActionResponse; use codex_app_server_protocol::ThreadBackgroundTerminalsCleanParams; use codex_app_server_protocol::ThreadBackgroundTerminalsCleanResponse; use codex_app_server_protocol::ThreadCompactStartParams; use codex_app_server_protocol::ThreadCompactStartResponse; use codex_app_server_protocol::ThreadForkParams; use codex_app_server_protocol::ThreadForkResponse; use codex_app_server_protocol::ThreadGoalClearParams; use codex_app_server_protocol::ThreadGoalClearResponse; use codex_app_server_protocol::ThreadGoalGetParams; use codex_app_server_protocol::ThreadGoalGetResponse; use codex_app_server_protocol::ThreadGoalSetParams; use codex_app_server_protocol::ThreadGoalSetResponse; use codex_app_server_protocol::ThreadGoalStatus; use codex_app_server_protocol::ThreadInjectItemsParams; use codex_app_server_protocol::ThreadInjectItemsResponse; 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::ThreadMemoryMode; use codex_app_server_protocol::ThreadMemoryModeSetParams; use codex_app_server_protocol::ThreadMemoryModeSetResponse; use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadRealtimeAppendAudioParams; use codex_app_server_protocol::ThreadRealtimeAppendAudioResponse; use codex_app_server_protocol::ThreadRealtimeAudioChunk; use codex_app_server_protocol::ThreadRealtimeStartParams; use codex_app_server_protocol::ThreadRealtimeStartResponse; use codex_app_server_protocol::ThreadRealtimeStartTransport; use codex_app_server_protocol::ThreadRealtimeStopParams; use codex_app_server_protocol::ThreadRealtimeStopResponse; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadRollbackParams; use codex_app_server_protocol::ThreadRollbackResponse; use codex_app_server_protocol::ThreadSetNameParams; use codex_app_server_protocol::ThreadSetNameResponse; use codex_app_server_protocol::ThreadShellCommandParams; use codex_app_server_protocol::ThreadShellCommandResponse; use codex_app_server_protocol::ThreadSource; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStartSource; use codex_app_server_protocol::ThreadUnsubscribeParams; use codex_app_server_protocol::ThreadUnsubscribeResponse; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnInterruptResponse; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnSteerParams; use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; use codex_otel::TelemetryAuthMode; use codex_protocol::ThreadId; use codex_protocol::approvals::GuardianAssessmentEvent; use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelAvailabilityNux; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelServiceTier; use codex_protocol::openai_models::ModelUpgrade; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_utils_absolute_path::AbsolutePathBuf; use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; use std::collections::HashMap; use std::path::PathBuf; fn bootstrap_request_error(context: &'static str, err: TypedRequestError) -> color_eyre::Report { color_eyre::eyre::eyre!("{context}: {err}") } /// Data collected during the TUI bootstrap phase that the main event loop /// needs to configure the UI, telemetry, and initial rate-limit prefetch. /// /// Rate-limit snapshots are intentionally **not** included here; they are /// fetched asynchronously after bootstrap returns so that the TUI can render /// its first frame without waiting for the rate-limit round-trip. pub(crate) struct AppServerBootstrap { pub(crate) account_email: Option, pub(crate) auth_mode: Option, pub(crate) status_account_display: Option, pub(crate) plan_type: Option, /// Whether the configured model provider needs OpenAI-style auth. Combined /// with `has_chatgpt_account` to decide if a startup rate-limit prefetch /// should be fired. pub(crate) requires_openai_auth: bool, pub(crate) default_model: String, pub(crate) feedback_audience: FeedbackAudience, pub(crate) has_chatgpt_account: bool, pub(crate) available_models: Vec, } pub(crate) struct AppServerSession { client: AppServerClient, next_request_id: i64, remote_cwd_override: Option, } #[derive(Clone, Copy)] enum ThreadParamsMode { Embedded, Remote, } impl ThreadParamsMode { fn model_provider_from_config(self, config: &Config) -> Option { match self { Self::Embedded => Some(config.model_provider_id.clone()), Self::Remote => None, } } } pub(crate) struct AppServerStartedThread { pub(crate) session: ThreadSessionState, pub(crate) turns: Vec, } impl AppServerSession { pub(crate) fn new(client: AppServerClient) -> Self { Self { client, next_request_id: 1, remote_cwd_override: None, } } pub(crate) fn with_remote_cwd_override(mut self, remote_cwd_override: Option) -> Self { self.remote_cwd_override = remote_cwd_override; self } pub(crate) fn remote_cwd_override(&self) -> Option<&std::path::Path> { self.remote_cwd_override.as_deref() } pub(crate) fn is_remote(&self) -> bool { matches!(self.client, AppServerClient::Remote(_)) } pub(crate) async fn bootstrap(&mut self, config: &Config) -> Result { let account = self.read_account().await?; let model_request_id = self.next_request_id(); let models: ModelListResponse = self .client .request_typed(ClientRequest::ModelList { request_id: model_request_id, params: ModelListParams { cursor: None, limit: None, include_hidden: Some(true), }, }) .await .map_err(|err| { bootstrap_request_error("model/list failed during TUI bootstrap", err) })?; let available_models = models .data .into_iter() .map(model_preset_from_api_model) .collect::>(); let default_model = config .model .clone() .or_else(|| { available_models .iter() .find(|model| model.is_default) .map(|model| model.model.clone()) }) .or_else(|| available_models.first().map(|model| model.model.clone())) .wrap_err("model/list returned no models for TUI bootstrap")?; let ( account_email, auth_mode, status_account_display, plan_type, feedback_audience, has_chatgpt_account, ) = match account.account { Some(Account::ApiKey {}) => ( None, Some(TelemetryAuthMode::ApiKey), Some(StatusAccountDisplay::ApiKey), None, FeedbackAudience::External, false, ), Some(Account::Chatgpt { email, plan_type }) => { let feedback_audience = if email.ends_with("@openai.com") { FeedbackAudience::OpenAiEmployee } else { FeedbackAudience::External }; ( Some(email.clone()), Some(TelemetryAuthMode::Chatgpt), Some(StatusAccountDisplay::ChatGpt { email: Some(email), plan: Some(plan_type_display_name(plan_type)), }), Some(plan_type), feedback_audience, true, ) } Some(Account::AmazonBedrock {}) => { (None, None, None, None, FeedbackAudience::External, false) } None => (None, None, None, None, FeedbackAudience::External, false), }; Ok(AppServerBootstrap { account_email, auth_mode, status_account_display, plan_type, requires_openai_auth: account.requires_openai_auth, default_model, feedback_audience, has_chatgpt_account, available_models, }) } /// Fetches the current account info without refreshing the auth token. /// /// Used by both `bootstrap` (to populate the initial UI) and `get_login_status` /// (to check auth mode without the overhead of a full bootstrap). pub(crate) async fn read_account(&mut self) -> Result { let account_request_id = self.next_request_id(); self.client .request_typed(ClientRequest::GetAccount { request_id: account_request_id, params: GetAccountParams { refresh_token: false, }, }) .await .map_err(|err| bootstrap_request_error("account/read failed during TUI bootstrap", err)) } pub(crate) async fn external_agent_config_detect( &mut self, params: ExternalAgentConfigDetectParams, ) -> Result { let request_id = self.next_request_id(); self.client .request_typed(ClientRequest::ExternalAgentConfigDetect { request_id, params }) .await .wrap_err("externalAgentConfig/detect failed during TUI startup") } pub(crate) async fn external_agent_config_import( &mut self, migration_items: Vec, ) -> Result { let request_id = self.next_request_id(); self.client .request_typed(ClientRequest::ExternalAgentConfigImport { request_id, params: ExternalAgentConfigImportParams { migration_items }, }) .await .wrap_err("externalAgentConfig/import failed during TUI startup") } pub(crate) async fn next_event(&mut self) -> Option { self.client.next_event().await } pub(crate) async fn start_thread(&mut self, config: &Config) -> Result { self.start_thread_with_session_start_source(config, /*session_start_source*/ None) .await } pub(crate) async fn start_thread_with_session_start_source( &mut self, config: &Config, session_start_source: Option, ) -> Result { let request_id = self.next_request_id(); let response: ThreadStartResponse = self .client .request_typed(ClientRequest::ThreadStart { request_id, params: thread_start_params_from_config( config, self.thread_params_mode(), self.remote_cwd_override.as_deref(), session_start_source, ), }) .await .map_err(|err| { bootstrap_request_error("thread/start failed during TUI bootstrap", err) })?; started_thread_from_start_response(response, config, self.thread_params_mode()).await } pub(crate) async fn resume_thread( &mut self, config: Config, thread_id: ThreadId, ) -> Result { let request_id = self.next_request_id(); let response: ThreadResumeResponse = self .client .request_typed(ClientRequest::ThreadResume { request_id, params: thread_resume_params_from_config( config.clone(), thread_id, self.thread_params_mode(), self.remote_cwd_override.as_deref(), ), }) .await .map_err(|err| { bootstrap_request_error("thread/resume failed during TUI bootstrap", err) })?; let fork_parent_title = self .fork_parent_title_from_app_server(response.thread.forked_from_id.as_deref()) .await; let mut started = started_thread_from_resume_response(response, &config, self.thread_params_mode()) .await?; started.session.fork_parent_title = fork_parent_title; Ok(started) } pub(crate) async fn fork_thread( &mut self, config: Config, thread_id: ThreadId, ) -> Result { let request_id = self.next_request_id(); let response: ThreadForkResponse = self .client .request_typed(ClientRequest::ThreadFork { request_id, params: thread_fork_params_from_config( config.clone(), thread_id, self.thread_params_mode(), self.remote_cwd_override.as_deref(), ), }) .await .map_err(|err| { bootstrap_request_error("thread/fork failed during TUI bootstrap", err) })?; let fork_parent_title = self .fork_parent_title_from_app_server(response.thread.forked_from_id.as_deref()) .await; let mut started = started_thread_from_fork_response(response, &config, self.thread_params_mode()).await?; started.session.fork_parent_title = fork_parent_title; Ok(started) } fn thread_params_mode(&self) -> ThreadParamsMode { match &self.client { AppServerClient::InProcess(_) => ThreadParamsMode::Embedded, AppServerClient::Remote(_) => ThreadParamsMode::Remote, } } async fn fork_parent_title_from_app_server( &mut self, forked_from_id: Option<&str>, ) -> Option { let forked_from_id = forked_from_id?; let forked_from_id = match ThreadId::from_string(forked_from_id) { Ok(thread_id) => thread_id, Err(err) => { tracing::warn!("Failed to parse fork parent thread id from app server: {err}"); return None; } }; match self .thread_read(forked_from_id, /*include_turns*/ false) .await { Ok(thread) => thread.name, Err(err) => { tracing::warn!("Failed to read fork parent metadata from app server: {err}"); None } } } pub(crate) async fn thread_list( &mut self, params: ThreadListParams, ) -> Result { let request_id = self.next_request_id(); self.client .request_typed(ClientRequest::ThreadList { request_id, params }) .await .wrap_err("thread/list failed during TUI session lookup") } /// Lists thread ids that the app server currently holds in memory. /// /// Used by `App::backfill_loaded_subagent_threads` to discover subagent threads that were /// spawned before the TUI connected. The caller then fetches full metadata per thread via /// `thread_read` and walks the spawn tree. pub(crate) async fn thread_loaded_list( &mut self, params: ThreadLoadedListParams, ) -> Result { let request_id = self.next_request_id(); self.client .request_typed(ClientRequest::ThreadLoadedList { request_id, params }) .await .wrap_err("failed to list loaded threads from app server") } pub(crate) async fn thread_read( &mut self, thread_id: ThreadId, include_turns: bool, ) -> Result { let request_id = self.next_request_id(); let response: ThreadReadResponse = self .client .request_typed(ClientRequest::ThreadRead { request_id, params: ThreadReadParams { thread_id: thread_id.to_string(), include_turns, }, }) .await .wrap_err("thread/read failed during TUI session lookup")?; Ok(response.thread) } pub(crate) async fn thread_inject_items( &mut self, thread_id: ThreadId, items: Vec, ) -> Result { let items = items .into_iter() .map(serde_json::to_value) .collect::, _>>() .wrap_err("failed to encode thread/inject_items payload")?; let request_id = self.next_request_id(); self.client .request_typed(ClientRequest::ThreadInjectItems { request_id, params: ThreadInjectItemsParams { thread_id: thread_id.to_string(), items, }, }) .await .wrap_err("thread/inject_items failed during TUI side conversation setup") } #[allow(clippy::too_many_arguments)] pub(crate) async fn turn_start( &mut self, thread_id: ThreadId, items: Vec, cwd: PathBuf, approval_policy: AskForApproval, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, active_permission_profile: Option, model: String, effort: Option, summary: Option, service_tier: Option>, collaboration_mode: Option, personality: Option, output_schema: Option, ) -> Result { let request_id = self.next_request_id(); let permissions = turn_permissions_selection(active_permission_profile, self.thread_params_mode()); self.client .request_typed(ClientRequest::TurnStart { request_id, params: TurnStartParams { thread_id: thread_id.to_string(), input: items, responsesapi_client_metadata: None, environments: None, cwd: Some(cwd), workspace_roots: None, approval_policy: Some(approval_policy), approvals_reviewer: Some(approvals_reviewer.into()), sandbox_policy: None, permissions, model: Some(model), service_tier, effort, summary, personality, output_schema, collaboration_mode, }, }) .await .wrap_err("turn/start failed in TUI") } pub(crate) async fn turn_interrupt( &mut self, thread_id: ThreadId, turn_id: String, ) -> Result<()> { let request_id = self.next_request_id(); let _: TurnInterruptResponse = self .client .request_typed(ClientRequest::TurnInterrupt { request_id, params: TurnInterruptParams { thread_id: thread_id.to_string(), turn_id, }, }) .await .wrap_err("turn/interrupt failed in TUI")?; Ok(()) } pub(crate) async fn startup_interrupt(&mut self, thread_id: ThreadId) -> Result<()> { self.turn_interrupt(thread_id, String::new()).await } pub(crate) async fn turn_steer( &mut self, thread_id: ThreadId, turn_id: String, items: Vec, ) -> std::result::Result { let request_id = self.next_request_id(); self.client .request_typed(ClientRequest::TurnSteer { request_id, params: TurnSteerParams { thread_id: thread_id.to_string(), input: items, responsesapi_client_metadata: None, expected_turn_id: turn_id, }, }) .await } pub(crate) async fn thread_set_name( &mut self, thread_id: ThreadId, name: String, ) -> Result<()> { let request_id = self.next_request_id(); let _: ThreadSetNameResponse = self .client .request_typed(ClientRequest::ThreadSetName { request_id, params: ThreadSetNameParams { thread_id: thread_id.to_string(), name, }, }) .await .wrap_err("thread/name/set failed in TUI")?; Ok(()) } pub(crate) async fn thread_memory_mode_set( &mut self, thread_id: ThreadId, mode: ThreadMemoryMode, ) -> Result<()> { let request_id = self.next_request_id(); let _: ThreadMemoryModeSetResponse = self .client .request_typed(ClientRequest::ThreadMemoryModeSet { request_id, params: ThreadMemoryModeSetParams { thread_id: thread_id.to_string(), mode, }, }) .await .wrap_err("thread/memoryMode/set failed in TUI")?; Ok(()) } pub(crate) async fn memory_reset(&mut self) -> Result<()> { let request_id = self.next_request_id(); let _: MemoryResetResponse = self .client .request_typed(ClientRequest::MemoryReset { request_id, params: None, }) .await .wrap_err("memory/reset failed in TUI")?; Ok(()) } pub(crate) async fn thread_goal_get( &mut self, thread_id: ThreadId, ) -> Result { let request_id = self.next_request_id(); self.client .request_typed(ClientRequest::ThreadGoalGet { request_id, params: ThreadGoalGetParams { thread_id: thread_id.to_string(), }, }) .await .wrap_err("thread/goal/get failed in TUI") } pub(crate) async fn thread_goal_set( &mut self, thread_id: ThreadId, objective: Option, status: Option, token_budget: Option>, ) -> Result { let request_id = self.next_request_id(); self.client .request_typed(ClientRequest::ThreadGoalSet { request_id, params: ThreadGoalSetParams { thread_id: thread_id.to_string(), objective, status, token_budget, }, }) .await .wrap_err("thread/goal/set failed in TUI") } pub(crate) async fn thread_goal_clear( &mut self, thread_id: ThreadId, ) -> Result { let request_id = self.next_request_id(); self.client .request_typed(ClientRequest::ThreadGoalClear { request_id, params: ThreadGoalClearParams { thread_id: thread_id.to_string(), }, }) .await .wrap_err("thread/goal/clear failed in TUI") } pub(crate) async fn logout_account(&mut self) -> Result<()> { let request_id = self.next_request_id(); let _: LogoutAccountResponse = self .client .request_typed(ClientRequest::LogoutAccount { request_id, params: None, }) .await .wrap_err("account/logout failed in TUI")?; Ok(()) } pub(crate) async fn thread_unsubscribe(&mut self, thread_id: ThreadId) -> Result<()> { let request_id = self.next_request_id(); let _: ThreadUnsubscribeResponse = self .client .request_typed(ClientRequest::ThreadUnsubscribe { request_id, params: ThreadUnsubscribeParams { thread_id: thread_id.to_string(), }, }) .await .wrap_err("thread/unsubscribe failed in TUI")?; Ok(()) } pub(crate) async fn thread_compact_start(&mut self, thread_id: ThreadId) -> Result<()> { let request_id = self.next_request_id(); let _: ThreadCompactStartResponse = self .client .request_typed(ClientRequest::ThreadCompactStart { request_id, params: ThreadCompactStartParams { thread_id: thread_id.to_string(), }, }) .await .wrap_err("thread/compact/start failed in TUI")?; Ok(()) } pub(crate) async fn thread_shell_command( &mut self, thread_id: ThreadId, command: String, ) -> Result<()> { let request_id = self.next_request_id(); let _: ThreadShellCommandResponse = self .client .request_typed(ClientRequest::ThreadShellCommand { request_id, params: ThreadShellCommandParams { thread_id: thread_id.to_string(), command, }, }) .await .wrap_err("thread/shellCommand failed in TUI")?; Ok(()) } pub(crate) async fn thread_approve_guardian_denied_action( &mut self, thread_id: ThreadId, event: &GuardianAssessmentEvent, ) -> Result<()> { let request_id = self.next_request_id(); let _: ThreadApproveGuardianDeniedActionResponse = self .client .request_typed(ClientRequest::ThreadApproveGuardianDeniedAction { request_id, params: ThreadApproveGuardianDeniedActionParams { thread_id: thread_id.to_string(), event: serde_json::to_value(event) .wrap_err("failed to serialize Auto Review denial event")?, }, }) .await .wrap_err("thread/approveGuardianDeniedAction failed in TUI")?; Ok(()) } pub(crate) async fn thread_background_terminals_clean( &mut self, thread_id: ThreadId, ) -> Result<()> { let request_id = self.next_request_id(); let _: ThreadBackgroundTerminalsCleanResponse = self .client .request_typed(ClientRequest::ThreadBackgroundTerminalsClean { request_id, params: ThreadBackgroundTerminalsCleanParams { thread_id: thread_id.to_string(), }, }) .await .wrap_err("thread/backgroundTerminals/clean failed in TUI")?; Ok(()) } pub(crate) async fn thread_rollback( &mut self, thread_id: ThreadId, num_turns: u32, ) -> Result { let request_id = self.next_request_id(); self.client .request_typed(ClientRequest::ThreadRollback { request_id, params: ThreadRollbackParams { thread_id: thread_id.to_string(), num_turns, }, }) .await .wrap_err("thread/rollback failed in TUI") } pub(crate) async fn review_start( &mut self, thread_id: ThreadId, target: ReviewTarget, ) -> Result { let request_id = self.next_request_id(); self.client .request_typed(ClientRequest::ReviewStart { request_id, params: ReviewStartParams { thread_id: thread_id.to_string(), target, delivery: Some(ReviewDelivery::Inline), }, }) .await .wrap_err("review/start failed in TUI") } pub(crate) async fn skills_list( &mut self, params: SkillsListParams, ) -> Result { let request_id = self.next_request_id(); self.client .request_typed(ClientRequest::SkillsList { request_id, params }) .await .wrap_err("skills/list failed in TUI") } pub(crate) async fn reload_user_config(&mut self) -> Result<()> { let request_id = self.next_request_id(); let _: ConfigWriteResponse = self .client .request_typed(ClientRequest::ConfigBatchWrite { request_id, params: ConfigBatchWriteParams { edits: Vec::new(), file_path: None, expected_version: None, reload_user_config: true, }, }) .await .wrap_err("config/batchWrite failed while reloading user config in TUI")?; Ok(()) } pub(crate) async fn thread_realtime_start( &mut self, thread_id: ThreadId, transport: Option, voice: Option, ) -> Result<()> { let request_id = self.next_request_id(); let params = thread_realtime_start_params(thread_id, transport, voice)?; let _: ThreadRealtimeStartResponse = self .client .request_typed(ClientRequest::ThreadRealtimeStart { request_id, params }) .await .wrap_err("thread/realtime/start failed in TUI")?; Ok(()) } pub(crate) async fn thread_realtime_audio( &mut self, thread_id: ThreadId, frame: ThreadRealtimeAudioChunk, ) -> Result<()> { let request_id = self.next_request_id(); let _: ThreadRealtimeAppendAudioResponse = self .client .request_typed(ClientRequest::ThreadRealtimeAppendAudio { request_id, params: ThreadRealtimeAppendAudioParams { thread_id: thread_id.to_string(), audio: frame, }, }) .await .wrap_err("thread/realtime/appendAudio failed in TUI")?; Ok(()) } pub(crate) async fn thread_realtime_stop(&mut self, thread_id: ThreadId) -> Result<()> { let request_id = self.next_request_id(); let _: ThreadRealtimeStopResponse = self .client .request_typed(ClientRequest::ThreadRealtimeStop { request_id, params: ThreadRealtimeStopParams { thread_id: thread_id.to_string(), }, }) .await .wrap_err("thread/realtime/stop failed in TUI")?; Ok(()) } pub(crate) async fn reject_server_request( &self, request_id: RequestId, error: JSONRPCErrorError, ) -> std::io::Result<()> { self.client.reject_server_request(request_id, error).await } pub(crate) async fn resolve_server_request( &self, request_id: RequestId, result: serde_json::Value, ) -> std::io::Result<()> { self.client.resolve_server_request(request_id, result).await } pub(crate) async fn shutdown(self) -> std::io::Result<()> { self.client.shutdown().await } pub(crate) fn request_handle(&self) -> AppServerRequestHandle { self.client.request_handle() } fn next_request_id(&mut self) -> RequestId { let request_id = self.next_request_id; self.next_request_id += 1; RequestId::Integer(request_id) } } fn thread_realtime_start_params( thread_id: ThreadId, transport: Option, voice: Option, ) -> Result { let mut value = serde_json::Map::new(); value.insert( "threadId".to_string(), serde_json::Value::String(thread_id.to_string()), ); value.insert( "outputModality".to_string(), serde_json::Value::String("audio".to_string()), ); if let Some(transport) = transport { value.insert( "transport".to_string(), serde_json::to_value(transport).wrap_err("serializing realtime transport")?, ); } if let Some(voice) = voice { value.insert("voice".to_string(), voice); } serde_json::from_value(serde_json::Value::Object(value)) .wrap_err("mapping TUI realtime start params to app-server params") } pub(crate) fn status_account_display_from_auth_mode( auth_mode: Option, plan_type: Option, ) -> Option { match auth_mode { Some(AuthMode::ApiKey) => Some(StatusAccountDisplay::ApiKey), Some(AuthMode::Chatgpt) | Some(AuthMode::ChatgptAuthTokens) | Some(AuthMode::AgentIdentity) => Some(StatusAccountDisplay::ChatGpt { email: None, plan: plan_type.map(plan_type_display_name), }), None => None, } } fn model_preset_from_api_model(model: ApiModel) -> ModelPreset { let upgrade = model.upgrade.map(|upgrade_id| { let upgrade_info = model.upgrade_info.clone(); ModelUpgrade { id: upgrade_id, reasoning_effort_mapping: None, migration_config_key: model.model.clone(), model_link: upgrade_info .as_ref() .and_then(|info| info.model_link.clone()), upgrade_copy: upgrade_info .as_ref() .and_then(|info| info.upgrade_copy.clone()), migration_markdown: upgrade_info.and_then(|info| info.migration_markdown), } }); ModelPreset { id: model.id, model: model.model, display_name: model.display_name, description: model.description, default_reasoning_effort: model.default_reasoning_effort, supported_reasoning_efforts: model .supported_reasoning_efforts .into_iter() .map(|effort| ReasoningEffortPreset { effort: effort.reasoning_effort, description: effort.description, }) .collect(), supports_personality: model.supports_personality, additional_speed_tiers: model.additional_speed_tiers, service_tiers: model .service_tiers .into_iter() .map(|service_tier| ModelServiceTier { id: service_tier.id, name: service_tier.name, description: service_tier.description, }) .collect(), is_default: model.is_default, upgrade, show_in_picker: !model.hidden, availability_nux: model.availability_nux.map(|nux| ModelAvailabilityNux { message: nux.message, }), // `model/list` already returns models filtered for the active client/auth context. supported_in_api: true, input_modalities: model.input_modalities, } } fn approvals_reviewer_override_from_config( config: &Config, ) -> Option { Some(config.approvals_reviewer.into()) } fn config_request_overrides_from_config( config: &Config, ) -> Option> { let mut overrides = HashMap::new(); let mut insert = |key: &str, value: Option| { if let Some(value) = value { overrides.insert(key.to_string(), serde_json::Value::String(value)); } }; insert("profile", config.active_profile.clone()); insert( "model_reasoning_effort", config .model_reasoning_effort .map(|effort| effort.to_string()), ); insert( "model_reasoning_summary", config .model_reasoning_summary .map(|summary| summary.to_string()), ); insert( "model_verbosity", config .model_verbosity .map(|verbosity| verbosity.to_string()), ); insert( "personality", config .personality .map(|personality| personality.to_string()), ); insert( "web_search", Some(config.web_search_mode.value().to_string()), ); Some(overrides) } fn service_tier_override_from_config(config: &Config) -> Option> { config .service_tier .clone() .map(Some) .or_else(|| (config.notices.fast_default_opt_out == Some(true)).then_some(None)) } fn sandbox_mode_from_permission_profile( permission_profile: &PermissionProfile, cwd: &std::path::Path, ) -> Option { match permission_profile { PermissionProfile::Disabled => { Some(codex_app_server_protocol::SandboxMode::DangerFullAccess) } PermissionProfile::External { .. } => None, PermissionProfile::Managed { .. } => { let file_system_policy = permission_profile.file_system_sandbox_policy(); if file_system_policy.has_full_disk_write_access() { permission_profile .network_sandbox_policy() .is_enabled() .then_some(codex_app_server_protocol::SandboxMode::DangerFullAccess) } else if file_system_policy.can_write_path_with_cwd(cwd, cwd) { Some(codex_app_server_protocol::SandboxMode::WorkspaceWrite) } else { Some(codex_app_server_protocol::SandboxMode::ReadOnly) } } } } fn permissions_selection_from_active_profile( active: ActivePermissionProfile, ) -> PermissionProfileSelectionParams { PermissionProfileSelectionParams { id: active.id } } fn turn_permissions_selection( active_permission_profile: Option, thread_params_mode: ThreadParamsMode, ) -> Option { if matches!(thread_params_mode, ThreadParamsMode::Remote) { return None; } active_permission_profile.map(permissions_selection_from_active_profile) } fn permissions_selection_from_config( config: &Config, thread_params_mode: ThreadParamsMode, ) -> Option { if matches!(thread_params_mode, ThreadParamsMode::Remote) { return None; } config .permissions .active_permission_profile() .map(permissions_selection_from_active_profile) } fn thread_start_params_from_config( config: &Config, thread_params_mode: ThreadParamsMode, remote_cwd_override: Option<&std::path::Path>, session_start_source: Option, ) -> ThreadStartParams { let permissions = permissions_selection_from_config(config, thread_params_mode); let sandbox = permissions .is_none() .then(|| { sandbox_mode_from_permission_profile( &config.permissions.permission_profile(), config.cwd.as_path(), ) }) .flatten(); ThreadStartParams { model: config.model.clone(), model_provider: thread_params_mode.model_provider_from_config(config), service_tier: service_tier_override_from_config(config), cwd: thread_cwd_from_config(config, thread_params_mode, remote_cwd_override), workspace_roots: thread_start_workspace_roots_from_config(config, thread_params_mode), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(config), sandbox, permissions, config: config_request_overrides_from_config(config), ephemeral: Some(config.ephemeral), session_start_source, thread_source: Some(ThreadSource::User), persist_extended_history: false, ..ThreadStartParams::default() } } fn thread_resume_params_from_config( config: Config, thread_id: ThreadId, thread_params_mode: ThreadParamsMode, remote_cwd_override: Option<&std::path::Path>, ) -> ThreadResumeParams { let permissions = permissions_selection_from_config(&config, thread_params_mode); ThreadResumeParams { thread_id: thread_id.to_string(), model: config.model.clone(), model_provider: thread_params_mode.model_provider_from_config(&config), service_tier: service_tier_override_from_config(&config), cwd: thread_cwd_from_config(&config, thread_params_mode, remote_cwd_override), workspace_roots: None, approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(&config), sandbox: None, permissions, config: config_request_overrides_from_config(&config), persist_extended_history: false, ..ThreadResumeParams::default() } } fn thread_fork_params_from_config( config: Config, thread_id: ThreadId, thread_params_mode: ThreadParamsMode, remote_cwd_override: Option<&std::path::Path>, ) -> ThreadForkParams { let permissions = permissions_selection_from_config(&config, thread_params_mode); ThreadForkParams { thread_id: thread_id.to_string(), model: config.model.clone(), model_provider: thread_params_mode.model_provider_from_config(&config), service_tier: service_tier_override_from_config(&config), cwd: thread_cwd_from_config(&config, thread_params_mode, remote_cwd_override), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(&config), sandbox: None, permissions, config: config_request_overrides_from_config(&config), base_instructions: config.base_instructions.clone(), developer_instructions: config.developer_instructions.clone(), ephemeral: config.ephemeral, thread_source: Some(ThreadSource::User), persist_extended_history: false, ..ThreadForkParams::default() } } fn thread_start_workspace_roots_from_config( config: &Config, thread_params_mode: ThreadParamsMode, ) -> Option> { match thread_params_mode { ThreadParamsMode::Embedded => Some(config.workspace_roots.clone()), ThreadParamsMode::Remote => None, } } fn thread_cwd_from_config( config: &Config, thread_params_mode: ThreadParamsMode, remote_cwd_override: Option<&std::path::Path>, ) -> Option { match thread_params_mode { ThreadParamsMode::Embedded => Some(config.cwd.to_string_lossy().to_string()), ThreadParamsMode::Remote => { remote_cwd_override.map(|cwd| cwd.to_string_lossy().to_string()) } } } async fn started_thread_from_start_response( response: ThreadStartResponse, config: &Config, thread_params_mode: ThreadParamsMode, ) -> Result { let session = thread_session_state_from_thread_start_response(&response, config, thread_params_mode) .await .map_err(color_eyre::eyre::Report::msg)?; Ok(AppServerStartedThread { session, turns: response.thread.turns, }) } async fn started_thread_from_resume_response( response: ThreadResumeResponse, config: &Config, thread_params_mode: ThreadParamsMode, ) -> Result { let session = thread_session_state_from_thread_resume_response(&response, config, thread_params_mode) .await .map_err(color_eyre::eyre::Report::msg)?; Ok(AppServerStartedThread { session, turns: response.thread.turns, }) } async fn started_thread_from_fork_response( response: ThreadForkResponse, config: &Config, thread_params_mode: ThreadParamsMode, ) -> Result { let session = thread_session_state_from_thread_fork_response(&response, config, thread_params_mode) .await .map_err(color_eyre::eyre::Report::msg)?; Ok(AppServerStartedThread { session, turns: response.thread.turns, }) } async fn thread_session_state_from_thread_start_response( response: &ThreadStartResponse, config: &Config, thread_params_mode: ThreadParamsMode, ) -> Result { let permission_profile = permission_profile_from_thread_response( &response.sandbox, response.permission_profile.as_ref(), response.cwd.as_path(), config, thread_params_mode, ); thread_session_state_from_thread_response( &response.thread.id, response.thread.forked_from_id.clone(), response.thread.name.clone(), response.thread.path.clone(), response.model.clone(), response.model_provider.clone(), response.service_tier.clone(), response.approval_policy, response.approvals_reviewer.to_core(), permission_profile, response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), response.instruction_sources.clone(), response.reasoning_effort, config, ) .await } async fn thread_session_state_from_thread_resume_response( response: &ThreadResumeResponse, config: &Config, thread_params_mode: ThreadParamsMode, ) -> Result { let permission_profile = permission_profile_from_thread_response( &response.sandbox, response.permission_profile.as_ref(), response.cwd.as_path(), config, thread_params_mode, ); thread_session_state_from_thread_response( &response.thread.id, response.thread.forked_from_id.clone(), response.thread.name.clone(), response.thread.path.clone(), response.model.clone(), response.model_provider.clone(), response.service_tier.clone(), response.approval_policy, response.approvals_reviewer.to_core(), permission_profile, response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), response.instruction_sources.clone(), response.reasoning_effort, config, ) .await } async fn thread_session_state_from_thread_fork_response( response: &ThreadForkResponse, config: &Config, thread_params_mode: ThreadParamsMode, ) -> Result { let permission_profile = permission_profile_from_thread_response( &response.sandbox, response.permission_profile.as_ref(), response.cwd.as_path(), config, thread_params_mode, ); thread_session_state_from_thread_response( &response.thread.id, response.thread.forked_from_id.clone(), response.thread.name.clone(), response.thread.path.clone(), response.model.clone(), response.model_provider.clone(), response.service_tier.clone(), response.approval_policy, response.approvals_reviewer.to_core(), permission_profile, response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), response.instruction_sources.clone(), response.reasoning_effort, config, ) .await } fn permission_profile_from_thread_response( sandbox: &codex_app_server_protocol::SandboxPolicy, permission_profile: Option<&codex_app_server_protocol::PermissionProfile>, cwd: &std::path::Path, config: &Config, thread_params_mode: ThreadParamsMode, ) -> PermissionProfile { if let Some(permission_profile) = permission_profile { return permission_profile.clone().into(); } match thread_params_mode { ThreadParamsMode::Embedded => config.permissions.permission_profile(), ThreadParamsMode::Remote => { PermissionProfile::from_legacy_sandbox_policy_for_cwd(&sandbox.to_core(), cwd) } } } #[expect( clippy::too_many_arguments, reason = "session mapping keeps explicit fields" )] async fn thread_session_state_from_thread_response( thread_id: &str, forked_from_id: Option, thread_name: Option, rollout_path: Option, model: String, model_provider_id: String, service_tier: Option, approval_policy: AskForApproval, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, permission_profile: PermissionProfile, active_permission_profile: Option, cwd: AbsolutePathBuf, instruction_source_paths: Vec, reasoning_effort: Option, config: &Config, ) -> Result { let thread_id = ThreadId::from_string(thread_id) .map_err(|err| format!("thread id `{thread_id}` is invalid: {err}"))?; let forked_from_id = forked_from_id .as_deref() .map(ThreadId::from_string) .transpose() .map_err(|err| format!("forked_from_id is invalid: {err}"))?; let history_config = codex_message_history::HistoryConfig::new(config.codex_home.clone(), &config.history); let (log_id, entry_count) = codex_message_history::history_metadata(&history_config).await; Ok(ThreadSessionState { thread_id, forked_from_id, fork_parent_title: None, thread_name, model, model_provider_id, service_tier, approval_policy, approvals_reviewer, permission_profile, active_permission_profile, cwd, instruction_source_paths, reasoning_effort, message_history: Some(MessageHistoryMetadata { log_id, entry_count, }), network_proxy: None, rollout_path, }) } pub(crate) fn app_server_rate_limit_snapshots( response: GetAccountRateLimitsResponse, ) -> Vec { let mut snapshots = Vec::new(); snapshots.push(response.rate_limits); if let Some(by_limit_id) = response.rate_limits_by_limit_id { snapshots.extend(by_limit_id.into_values()); } snapshots } #[cfg(test)] mod tests { use super::*; use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; use codex_app_server_protocol::FileSystemAccessMode; use codex_app_server_protocol::FileSystemPath; use codex_app_server_protocol::FileSystemSandboxEntry; use codex_app_server_protocol::FileSystemSpecialPath; use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; use codex_app_server_protocol::PermissionProfileFileSystemPermissions; use codex_app_server_protocol::PermissionProfileNetworkPermissions; use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnStatus; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::Verbosity; use codex_protocol::config_types::WebSearchMode; use codex_protocol::openai_models::ReasoningEffort; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; use pretty_assertions::assert_eq; use tempfile::TempDir; async fn build_config(temp_dir: &TempDir) -> Config { ConfigBuilder::default() .codex_home(temp_dir.path().to_path_buf()) .build() .await .expect("config should build") } #[tokio::test] async fn thread_start_params_include_cwd_for_embedded_sessions() { let temp_dir = tempfile::tempdir().expect("tempdir"); let config = ConfigBuilder::default() .codex_home(temp_dir.path().to_path_buf()) .harness_overrides(ConfigOverrides { default_permissions: Some(":workspace".to_string()), ..ConfigOverrides::default() }) .build() .await .expect("config should build"); let params = thread_start_params_from_config( &config, ThreadParamsMode::Embedded, /*remote_cwd_override*/ None, /*session_start_source*/ None, ); assert_eq!(params.cwd, Some(config.cwd.to_string_lossy().to_string())); assert_eq!(params.workspace_roots, Some(config.workspace_roots.clone())); assert_eq!(params.sandbox, None); assert_eq!( params.permissions, config .permissions .active_permission_profile() .map(permissions_selection_from_active_profile) ); assert_eq!(params.model_provider, Some(config.model_provider_id)); assert_eq!(params.thread_source, Some(ThreadSource::User)); } #[tokio::test] async fn thread_start_params_can_mark_clear_source() { let temp_dir = tempfile::tempdir().expect("tempdir"); let config = build_config(&temp_dir).await; let params = thread_start_params_from_config( &config, ThreadParamsMode::Embedded, /*remote_cwd_override*/ None, Some(ThreadStartSource::Clear), ); assert_eq!(params.session_start_source, Some(ThreadStartSource::Clear)); } #[test] fn embedded_turn_permissions_use_active_profile_selection() { let active_permission_profile = ActivePermissionProfile::new(":workspace"); let expected_permissions = permissions_selection_from_active_profile(active_permission_profile.clone()); let permissions = turn_permissions_selection(Some(active_permission_profile), ThreadParamsMode::Embedded); assert_eq!(permissions, Some(expected_permissions)); } #[test] fn embedded_turn_permissions_omit_overrides_without_active_profile() { let permissions = turn_permissions_selection( /*active_permission_profile*/ None, ThreadParamsMode::Embedded, ); assert_eq!(permissions, None); } #[test] fn remote_turn_permissions_omit_overrides_even_with_active_profile() { let permissions = turn_permissions_selection( Some(ActivePermissionProfile::new(":read-only")), ThreadParamsMode::Remote, ); assert_eq!(permissions, None); } #[tokio::test] async fn thread_lifecycle_params_omit_cwd_without_remote_override_for_remote_sessions() { let temp_dir = tempfile::tempdir().expect("tempdir"); let config = build_config(&temp_dir).await; let thread_id = ThreadId::new(); let expected_sandbox = sandbox_mode_from_permission_profile( &config.permissions.permission_profile(), config.cwd.as_path(), ); let start = thread_start_params_from_config( &config, ThreadParamsMode::Remote, /*remote_cwd_override*/ None, /*session_start_source*/ None, ); let resume = thread_resume_params_from_config( config.clone(), thread_id, ThreadParamsMode::Remote, /*remote_cwd_override*/ None, ); let fork = thread_fork_params_from_config( config, thread_id, ThreadParamsMode::Remote, /*remote_cwd_override*/ None, ); assert_eq!(start.cwd, None); assert_eq!(resume.cwd, None); assert_eq!(fork.cwd, None); assert_eq!(start.workspace_roots, None); assert_eq!(resume.workspace_roots, None); assert_eq!(fork.workspace_roots, None); assert_eq!(start.model_provider, None); assert_eq!(resume.model_provider, None); assert_eq!(fork.model_provider, None); assert_eq!(start.sandbox, expected_sandbox); assert_eq!(resume.sandbox, None); assert_eq!(fork.sandbox, None); assert_eq!(start.permissions, None); assert_eq!(resume.permissions, None); assert_eq!(fork.permissions, None); assert_eq!(start.thread_source, Some(ThreadSource::User)); assert_eq!(fork.thread_source, Some(ThreadSource::User)); } #[test] fn sandbox_mode_does_not_project_non_cwd_write_roots_for_remote_sessions() { let cwd = test_path_buf("/workspace/project").abs(); let extra_root = test_path_buf("/workspace/cache").abs(); let permission_profile: PermissionProfile = AppServerPermissionProfile::Managed { network: PermissionProfileNetworkPermissions { enabled: false }, file_system: PermissionProfileFileSystemPermissions::Restricted { entries: vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Root, }, access: FileSystemAccessMode::Read, }, FileSystemSandboxEntry { path: FileSystemPath::Path { path: extra_root }, access: FileSystemAccessMode::Write, }, ], glob_scan_max_depth: None, }, } .into(); assert_eq!( sandbox_mode_from_permission_profile(&permission_profile, cwd.as_path()), Some(codex_app_server_protocol::SandboxMode::ReadOnly) ); } #[test] fn sandbox_mode_projects_cwd_write_for_remote_sessions() { let cwd = test_path_buf("/workspace/project").abs(); let permission_profile: PermissionProfile = AppServerPermissionProfile::Managed { network: PermissionProfileNetworkPermissions { enabled: false }, file_system: PermissionProfileFileSystemPermissions::Restricted { entries: vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Root, }, access: FileSystemAccessMode::Read, }, FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::ProjectRoots { subpath: None }, }, access: FileSystemAccessMode::Write, }, ], glob_scan_max_depth: None, }, } .into(); assert_eq!( sandbox_mode_from_permission_profile(&permission_profile, cwd.as_path()), Some(codex_app_server_protocol::SandboxMode::WorkspaceWrite) ); } #[tokio::test] async fn thread_lifecycle_params_forward_explicit_remote_cwd_override_for_remote_sessions() { let temp_dir = tempfile::tempdir().expect("tempdir"); let config = build_config(&temp_dir).await; let thread_id = ThreadId::new(); let remote_cwd = PathBuf::from("repo/on/server"); let expected_sandbox = sandbox_mode_from_permission_profile( &config.permissions.permission_profile(), config.cwd.as_path(), ); let start = thread_start_params_from_config( &config, ThreadParamsMode::Remote, Some(remote_cwd.as_path()), /*session_start_source*/ None, ); let resume = thread_resume_params_from_config( config.clone(), thread_id, ThreadParamsMode::Remote, Some(remote_cwd.as_path()), ); let fork = thread_fork_params_from_config( config, thread_id, ThreadParamsMode::Remote, Some(remote_cwd.as_path()), ); assert_eq!(start.cwd.as_deref(), Some("repo/on/server")); assert_eq!(resume.cwd.as_deref(), Some("repo/on/server")); assert_eq!(fork.cwd.as_deref(), Some("repo/on/server")); assert_eq!(start.workspace_roots, None); assert_eq!(resume.workspace_roots, None); assert_eq!(fork.workspace_roots, None); assert_eq!(start.model_provider, None); assert_eq!(resume.model_provider, None); assert_eq!(fork.model_provider, None); assert_eq!(start.sandbox, expected_sandbox); assert_eq!(resume.sandbox, None); assert_eq!(fork.sandbox, None); assert_eq!(start.permissions, None); assert_eq!(resume.permissions, None); assert_eq!(fork.permissions, None); assert_eq!(start.thread_source, Some(ThreadSource::User)); assert_eq!(fork.thread_source, Some(ThreadSource::User)); } #[tokio::test] async fn thread_lifecycle_params_forward_model_reasoning_and_service_tier() { let temp_dir = tempfile::tempdir().expect("tempdir"); let mut config = build_config(&temp_dir).await; config.model_reasoning_effort = Some(ReasoningEffort::High); config.model_reasoning_summary = Some(ReasoningSummary::Detailed); config.model_verbosity = Some(Verbosity::Low); config.personality = Some(Personality::Pragmatic); config .web_search_mode .set(WebSearchMode::Disabled) .expect("test web search mode should be allowed"); config.service_tier = Some(ServiceTier::Fast.request_value().to_string()); let thread_id = ThreadId::new(); let start = thread_start_params_from_config( &config, ThreadParamsMode::Embedded, /*remote_cwd_override*/ None, /*session_start_source*/ None, ); let resume = thread_resume_params_from_config( config.clone(), thread_id, ThreadParamsMode::Embedded, /*remote_cwd_override*/ None, ); let fork = thread_fork_params_from_config( config, thread_id, ThreadParamsMode::Embedded, /*remote_cwd_override*/ None, ); let expected_service_tier = Some(Some(ServiceTier::Fast.request_value().to_string())); assert_eq!(start.service_tier, expected_service_tier); assert_eq!(resume.service_tier, expected_service_tier); assert_eq!(fork.service_tier, expected_service_tier); let string = |value: &str| serde_json::Value::String(value.to_string()); let expected_config = HashMap::from([ ("model_reasoning_effort".to_string(), string("high")), ("model_reasoning_summary".to_string(), string("detailed")), ("model_verbosity".to_string(), string("low")), ("personality".to_string(), string("pragmatic")), ("web_search".to_string(), string("disabled")), ]); assert_eq!(start.config, Some(expected_config.clone())); assert_eq!(resume.config, Some(expected_config.clone())); assert_eq!(fork.config, Some(expected_config)); } #[tokio::test] async fn config_request_overrides_preserve_implicit_personality_default() { let temp_dir = tempfile::tempdir().expect("tempdir"); let mut config = build_config(&temp_dir).await; config.personality = None; let implicit_overrides = config_request_overrides_from_config(&config).expect("config overrides"); assert!(!implicit_overrides.contains_key("personality")); config.personality = Some(Personality::None); let explicit_overrides = config_request_overrides_from_config(&config).expect("config overrides"); assert_eq!( explicit_overrides.get("personality"), Some(&serde_json::Value::String("none".to_string())) ); } #[tokio::test] async fn thread_fork_params_forward_instruction_overrides() { let temp_dir = tempfile::tempdir().expect("tempdir"); let mut config = build_config(&temp_dir).await; config.base_instructions = Some("Base override.".to_string()); config.developer_instructions = Some("Developer override.".to_string()); let thread_id = ThreadId::new(); let params = thread_fork_params_from_config( config, thread_id, ThreadParamsMode::Embedded, /*remote_cwd_override*/ None, ); assert_eq!(params.base_instructions.as_deref(), Some("Base override.")); assert_eq!( params.developer_instructions.as_deref(), Some("Developer override.") ); } #[tokio::test] async fn resume_response_restores_turns_from_thread_items() { let temp_dir = tempfile::tempdir().expect("tempdir"); let config = build_config(&temp_dir).await; let thread_id = ThreadId::new(); let forked_from_id = ThreadId::new(); let read_only_profile = PermissionProfile::read_only(); let response = ThreadResumeResponse { thread: codex_app_server_protocol::Thread { id: thread_id.to_string(), session_id: ThreadId::new().to_string(), forked_from_id: Some(forked_from_id.to_string()), preview: "hello".to_string(), ephemeral: false, model_provider: "openai".to_string(), created_at: 1, updated_at: 2, status: ThreadStatus::Idle, path: None, cwd: test_path_buf("/tmp/project").abs(), cli_version: "0.0.0".to_string(), source: codex_app_server_protocol::SessionSource::Cli, thread_source: None, agent_nickname: None, agent_role: None, git_info: None, name: None, turns: vec![Turn { id: "turn-1".to_string(), items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![ codex_app_server_protocol::ThreadItem::UserMessage { id: "user-1".to_string(), content: vec![codex_app_server_protocol::UserInput::Text { text: "hello from history".to_string(), text_elements: Vec::new(), }], }, codex_app_server_protocol::ThreadItem::AgentMessage { id: "assistant-1".to_string(), text: "assistant reply".to_string(), phase: None, memory_citation: None, }, ], status: TurnStatus::Completed, error: None, started_at: None, completed_at: None, duration_ms: None, }], }, model: "gpt-5.4".to_string(), model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp/project").abs(), workspace_roots: Vec::new(), instruction_sources: vec![test_path_buf("/tmp/project/AGENTS.md").abs()], approval_policy: codex_app_server_protocol::AskForApproval::Never, approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::User, sandbox: read_only_profile .to_legacy_sandbox_policy(test_path_buf("/tmp/project").as_path()) .expect("read-only profile must be legacy-compatible") .into(), permission_profile: Some(read_only_profile.clone().into()), active_permission_profile: None, reasoning_effort: None, }; let started = started_thread_from_resume_response( response.clone(), &config, ThreadParamsMode::Remote, ) .await .expect("resume response should map"); assert_eq!(started.session.forked_from_id, Some(forked_from_id)); assert_eq!( started.session.instruction_source_paths, response.instruction_sources ); assert_eq!(started.session.permission_profile, read_only_profile); assert_eq!(started.turns.len(), 1); assert_eq!(started.turns[0], response.thread.turns[0]); } #[tokio::test] async fn remote_thread_response_prefers_permission_profile_over_legacy_sandbox() { let temp_dir = tempfile::tempdir().expect("tempdir"); let config = build_config(&temp_dir).await; let cwd = test_path_buf("/tmp/project").abs(); let fallback_sandbox = PermissionProfile::read_only() .to_legacy_sandbox_policy(cwd.as_path()) .expect("read-only profile must be legacy-compatible") .into(); let response_profile = AppServerPermissionProfile::Managed { file_system: PermissionProfileFileSystemPermissions::Restricted { entries: vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Root, }, access: FileSystemAccessMode::Read, }, FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::ProjectRoots { subpath: Some(".env".into()), }, }, access: FileSystemAccessMode::None, }, ], glob_scan_max_depth: None, }, network: PermissionProfileNetworkPermissions { enabled: false }, }; let split_profile: PermissionProfile = response_profile.clone().into(); assert_eq!( permission_profile_from_thread_response( &fallback_sandbox, Some(&response_profile), cwd.as_path(), &config, ThreadParamsMode::Remote, ), split_profile ); } #[tokio::test] async fn embedded_thread_response_prefers_permission_profile_when_present() { let temp_dir = tempfile::tempdir().expect("tempdir"); let config = build_config(&temp_dir).await; let cwd = test_path_buf("/tmp/project").abs(); let response_profile = PermissionProfile::read_only().into(); assert_eq!( permission_profile_from_thread_response( &codex_app_server_protocol::SandboxPolicy::DangerFullAccess, Some(&response_profile), cwd.as_path(), &config, ThreadParamsMode::Embedded, ), PermissionProfile::read_only() ); } #[tokio::test] async fn session_configured_populates_history_metadata() { let temp_dir = tempfile::tempdir().expect("tempdir"); let config = build_config(&temp_dir).await; let thread_id = ThreadId::new(); let history_config = codex_message_history::HistoryConfig::new(config.codex_home.clone(), &config.history); codex_message_history::append_entry("older", &thread_id, &history_config) .await .expect("history append should succeed"); codex_message_history::append_entry("newer", &thread_id, &history_config) .await .expect("history append should succeed"); let session = thread_session_state_from_thread_response( &thread_id.to_string(), /*forked_from_id*/ None, Some("restore".to_string()), /*rollout_path*/ None, "gpt-5.4".to_string(), "openai".to_string(), /*service_tier*/ None, AskForApproval::Never, codex_protocol::config_types::ApprovalsReviewer::User, PermissionProfile::read_only(), /*active_permission_profile*/ None, test_path_buf("/tmp/project").abs(), Vec::new(), /*reasoning_effort*/ None, &config, ) .await .expect("session should map"); let metadata = session .message_history .expect("session should include message-history metadata"); assert_ne!(metadata.log_id, 0); assert_eq!(metadata.entry_count, 2); } #[tokio::test] async fn session_configured_preserves_fork_source_thread_id() { let temp_dir = tempfile::tempdir().expect("tempdir"); let config = build_config(&temp_dir).await; let thread_id = ThreadId::new(); let forked_from_id = ThreadId::new(); let session = thread_session_state_from_thread_response( &thread_id.to_string(), Some(forked_from_id.to_string()), Some("restore".to_string()), /*rollout_path*/ None, "gpt-5.4".to_string(), "openai".to_string(), /*service_tier*/ None, AskForApproval::Never, codex_protocol::config_types::ApprovalsReviewer::User, PermissionProfile::read_only(), /*active_permission_profile*/ None, test_path_buf("/tmp/project").abs(), Vec::new(), /*reasoning_effort*/ None, &config, ) .await .expect("session should map"); assert_eq!(session.forked_from_id, Some(forked_from_id)); } #[test] fn status_account_display_from_auth_mode_uses_remapped_plan_labels() { let business = status_account_display_from_auth_mode( Some(AuthMode::Chatgpt), Some(codex_protocol::account::PlanType::EnterpriseCbpUsageBased), ); assert!(matches!( business, Some(StatusAccountDisplay::ChatGpt { email: None, plan: Some(ref plan), }) if plan == "Enterprise" )); let team = status_account_display_from_auth_mode( Some(AuthMode::Chatgpt), Some(codex_protocol::account::PlanType::SelfServeBusinessUsageBased), ); assert!(matches!( team, Some(StatusAccountDisplay::ChatGpt { email: None, plan: Some(ref plan), }) if plan == "Business" )); } }