Files
codex/codex-rs/tui_app_server/src/app_server_session.rs
T
Eric Traut db89b73a9c Move TUI on top of app server (parallel code) (#14717)
This PR replicates the `tui` code directory and creates a temporary
parallel `tui_app_server` directory. It also implements a new feature
flag `tui_app_server` to select between the two tui implementations.

Once the new app-server-based TUI is stabilized, we'll delete the old
`tui` directory and feature flag.
2026-03-16 10:49:19 -06:00

1283 lines
46 KiB
Rust

use codex_app_server_client::AppServerClient;
use codex_app_server_client::AppServerEvent;
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_protocol::Account;
use codex_app_server_protocol::AuthMode;
use codex_app_server_protocol::ClientRequest;
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::Model as ApiModel;
use codex_app_server_protocol::ModelListParams;
use codex_app_server_protocol::ModelListResponse;
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::SkillsListParams;
use codex_app_server_protocol::SkillsListResponse;
use codex_app_server_protocol::Thread;
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::ThreadListParams;
use codex_app_server_protocol::ThreadListResponse;
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::ThreadRealtimeAppendTextParams;
use codex_app_server_protocol::ThreadRealtimeAppendTextResponse;
use codex_app_server_protocol::ThreadRealtimeStartParams;
use codex_app_server_protocol::ThreadRealtimeStartResponse;
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::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadUnsubscribeParams;
use codex_app_server_protocol::ThreadUnsubscribeResponse;
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_core::config::Config;
use codex_otel::TelemetryAuthMode;
use codex_protocol::ThreadId;
use codex_protocol::items::AgentMessageContent;
use codex_protocol::items::AgentMessageItem;
use codex_protocol::items::ContextCompactionItem;
use codex_protocol::items::ImageGenerationItem;
use codex_protocol::items::PlanItem;
use codex_protocol::items::ReasoningItem;
use codex_protocol::items::TurnItem;
use codex_protocol::items::UserMessageItem;
use codex_protocol::items::WebSearchItem;
use codex_protocol::openai_models::ModelAvailabilityNux;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ModelUpgrade;
use codex_protocol::openai_models::ReasoningEffortPreset;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::ConversationAudioParams;
use codex_protocol::protocol::ConversationStartParams;
use codex_protocol::protocol::ConversationTextParams;
use codex_protocol::protocol::CreditsSnapshot;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ItemCompletedEvent;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::protocol::ReviewTarget as CoreReviewTarget;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionConfiguredEvent;
use color_eyre::eyre::ContextCompat;
use color_eyre::eyre::Result;
use color_eyre::eyre::WrapErr;
use std::collections::HashMap;
use std::path::PathBuf;
use crate::bottom_pane::FeedbackAudience;
use crate::status::StatusAccountDisplay;
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>,
pub(crate) plan_type: Option<codex_protocol::account::PlanType>,
pub(crate) default_model: String,
pub(crate) feedback_audience: FeedbackAudience,
pub(crate) has_chatgpt_account: bool,
pub(crate) available_models: Vec<ModelPreset>,
pub(crate) rate_limit_snapshots: Vec<RateLimitSnapshot>,
}
pub(crate) struct AppServerSession {
client: AppServerClient,
next_request_id: i64,
}
#[derive(Clone, Copy)]
enum ThreadParamsMode {
Embedded,
Remote,
}
impl ThreadParamsMode {
fn model_provider_from_config(self, config: &Config) -> Option<String> {
match self {
Self::Embedded => Some(config.model_provider_id.clone()),
Self::Remote => None,
}
}
}
pub(crate) struct AppServerStartedThread {
pub(crate) session_configured: SessionConfiguredEvent,
}
impl AppServerSession {
pub(crate) fn new(client: AppServerClient) -> Self {
Self {
client,
next_request_id: 1,
}
}
pub(crate) fn is_remote(&self) -> bool {
matches!(self.client, AppServerClient::Remote(_))
}
pub(crate) async fn bootstrap(&mut self, config: &Config) -> Result<AppServerBootstrap> {
let account_request_id = self.next_request_id();
let account: GetAccountResponse = self
.client
.request_typed(ClientRequest::GetAccount {
request_id: account_request_id,
params: GetAccountParams {
refresh_token: false,
},
})
.await
.wrap_err("account/read failed during TUI bootstrap")?;
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
.wrap_err("model/list failed during TUI bootstrap")?;
let rate_limit_request_id = self.next_request_id();
let rate_limits: GetAccountRateLimitsResponse = self
.client
.request_typed(ClientRequest::GetAccountRateLimits {
request_id: rate_limit_request_id,
params: None,
})
.await
.wrap_err("account/rateLimits/read failed during TUI bootstrap")?;
let available_models = models
.data
.into_iter()
.map(model_preset_from_api_model)
.collect::<Vec<_>>();
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_auth_mode,
account_email,
auth_mode,
status_account_display,
plan_type,
feedback_audience,
has_chatgpt_account,
) = match account.account {
Some(Account::ApiKey {}) => (
Some(AuthMode::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(AuthMode::Chatgpt),
Some(email.clone()),
Some(TelemetryAuthMode::Chatgpt),
Some(StatusAccountDisplay::ChatGpt {
email: Some(email),
plan: Some(title_case(format!("{plan_type:?}").as_str())),
}),
Some(plan_type),
feedback_audience,
true,
)
}
None => (
None,
None,
None,
None,
None,
FeedbackAudience::External,
false,
),
};
Ok(AppServerBootstrap {
account_auth_mode,
account_email,
auth_mode,
status_account_display,
plan_type,
default_model,
feedback_audience,
has_chatgpt_account,
available_models,
rate_limit_snapshots: app_server_rate_limit_snapshots_to_core(rate_limits),
})
}
pub(crate) async fn next_event(&mut self) -> Option<AppServerEvent> {
self.client.next_event().await
}
pub(crate) async fn start_thread(&mut self, config: &Config) -> Result<AppServerStartedThread> {
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()),
})
.await
.wrap_err("thread/start failed during TUI bootstrap")?;
started_thread_from_start_response(&response)
}
pub(crate) async fn resume_thread(
&mut self,
config: Config,
thread_id: ThreadId,
) -> Result<AppServerStartedThread> {
let show_raw_agent_reasoning = config.show_raw_agent_reasoning;
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,
thread_id,
self.thread_params_mode(),
),
})
.await
.wrap_err("thread/resume failed during TUI bootstrap")?;
started_thread_from_resume_response(&response, show_raw_agent_reasoning)
}
pub(crate) async fn fork_thread(
&mut self,
config: Config,
thread_id: ThreadId,
) -> Result<AppServerStartedThread> {
let show_raw_agent_reasoning = config.show_raw_agent_reasoning;
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,
thread_id,
self.thread_params_mode(),
),
})
.await
.wrap_err("thread/fork failed during TUI bootstrap")?;
started_thread_from_fork_response(&response, show_raw_agent_reasoning)
}
fn thread_params_mode(&self) -> ThreadParamsMode {
match &self.client {
AppServerClient::InProcess(_) => ThreadParamsMode::Embedded,
AppServerClient::Remote(_) => ThreadParamsMode::Remote,
}
}
pub(crate) async fn thread_list(
&mut self,
params: ThreadListParams,
) -> Result<ThreadListResponse> {
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")
}
pub(crate) async fn thread_read(
&mut self,
thread_id: ThreadId,
include_turns: bool,
) -> Result<Thread> {
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)
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn turn_start(
&mut self,
thread_id: ThreadId,
items: Vec<codex_protocol::user_input::UserInput>,
cwd: PathBuf,
approval_policy: AskForApproval,
approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer,
sandbox_policy: SandboxPolicy,
model: String,
effort: Option<codex_protocol::openai_models::ReasoningEffort>,
summary: Option<codex_protocol::config_types::ReasoningSummary>,
service_tier: Option<Option<codex_protocol::config_types::ServiceTier>>,
collaboration_mode: Option<codex_protocol::config_types::CollaborationMode>,
personality: Option<codex_protocol::config_types::Personality>,
output_schema: Option<serde_json::Value>,
) -> Result<TurnStartResponse> {
let request_id = self.next_request_id();
self.client
.request_typed(ClientRequest::TurnStart {
request_id,
params: TurnStartParams {
thread_id: thread_id.to_string(),
input: items.into_iter().map(Into::into).collect(),
cwd: Some(cwd),
approval_policy: Some(approval_policy.into()),
approvals_reviewer: Some(approvals_reviewer.into()),
sandbox_policy: Some(sandbox_policy.into()),
model: Some(model),
service_tier,
effort,
summary,
personality,
output_schema,
collaboration_mode,
},
})
.await
.wrap_err("turn/start failed in app-server 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 app-server TUI")?;
Ok(())
}
pub(crate) async fn turn_steer(
&mut self,
thread_id: ThreadId,
turn_id: String,
items: Vec<codex_protocol::user_input::UserInput>,
) -> Result<TurnSteerResponse> {
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.into_iter().map(Into::into).collect(),
expected_turn_id: turn_id,
},
})
.await
.wrap_err("turn/steer failed in app-server TUI")
}
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 app-server 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 app-server 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 app-server 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 app-server TUI")?;
Ok(())
}
pub(crate) async fn thread_rollback(
&mut self,
thread_id: ThreadId,
num_turns: u32,
) -> Result<ThreadRollbackResponse> {
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 app-server TUI")
}
pub(crate) async fn review_start(
&mut self,
thread_id: ThreadId,
review_request: ReviewRequest,
) -> Result<ReviewStartResponse> {
let request_id = self.next_request_id();
self.client
.request_typed(ClientRequest::ReviewStart {
request_id,
params: ReviewStartParams {
thread_id: thread_id.to_string(),
target: review_target_to_app_server(review_request.target),
delivery: Some(ReviewDelivery::Inline),
},
})
.await
.wrap_err("review/start failed in app-server TUI")
}
pub(crate) async fn skills_list(
&mut self,
params: SkillsListParams,
) -> Result<SkillsListResponse> {
let request_id = self.next_request_id();
self.client
.request_typed(ClientRequest::SkillsList { request_id, params })
.await
.wrap_err("skills/list failed in app-server TUI")
}
pub(crate) async fn thread_realtime_start(
&mut self,
thread_id: ThreadId,
params: ConversationStartParams,
) -> Result<()> {
let request_id = self.next_request_id();
let _: ThreadRealtimeStartResponse = self
.client
.request_typed(ClientRequest::ThreadRealtimeStart {
request_id,
params: ThreadRealtimeStartParams {
thread_id: thread_id.to_string(),
prompt: params.prompt,
session_id: params.session_id,
},
})
.await
.wrap_err("thread/realtime/start failed in app-server TUI")?;
Ok(())
}
pub(crate) async fn thread_realtime_audio(
&mut self,
thread_id: ThreadId,
params: ConversationAudioParams,
) -> 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: params.frame.into(),
},
})
.await
.wrap_err("thread/realtime/appendAudio failed in app-server TUI")?;
Ok(())
}
pub(crate) async fn thread_realtime_text(
&mut self,
thread_id: ThreadId,
params: ConversationTextParams,
) -> Result<()> {
let request_id = self.next_request_id();
let _: ThreadRealtimeAppendTextResponse = self
.client
.request_typed(ClientRequest::ThreadRealtimeAppendText {
request_id,
params: ThreadRealtimeAppendTextParams {
thread_id: thread_id.to_string(),
text: params.text,
},
})
.await
.wrap_err("thread/realtime/appendText failed in app-server 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 app-server 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 title_case(s: &str) -> String {
if s.is_empty() {
return String::new();
}
let mut chars = s.chars();
let Some(first) = chars.next() else {
return String::new();
};
let rest = chars.as_str().to_ascii_lowercase();
first.to_uppercase().collect::<String>() + &rest
}
pub(crate) fn status_account_display_from_auth_mode(
auth_mode: Option<AuthMode>,
plan_type: Option<codex_protocol::account::PlanType>,
) -> Option<StatusAccountDisplay> {
match auth_mode {
Some(AuthMode::ApiKey) => Some(StatusAccountDisplay::ApiKey),
Some(AuthMode::Chatgpt) | Some(AuthMode::ChatgptAuthTokens) => {
Some(StatusAccountDisplay::ChatGpt {
email: None,
plan: plan_type.map(|plan_type| title_case(format!("{plan_type:?}").as_str())),
})
}
None => None,
}
}
#[allow(dead_code)]
pub(crate) fn feedback_audience_from_account_email(
account_email: Option<&str>,
) -> FeedbackAudience {
match account_email {
Some(email) if email.ends_with("@openai.com") => FeedbackAudience::OpenAiEmployee,
Some(_) | None => FeedbackAudience::External,
}
}
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,
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<codex_app_server_protocol::ApprovalsReviewer> {
Some(config.approvals_reviewer.into())
}
fn config_request_overrides_from_config(
config: &Config,
) -> Option<HashMap<String, serde_json::Value>> {
config.active_profile.as_ref().map(|profile| {
HashMap::from([(
"profile".to_string(),
serde_json::Value::String(profile.clone()),
)])
})
}
fn sandbox_mode_from_policy(
policy: SandboxPolicy,
) -> Option<codex_app_server_protocol::SandboxMode> {
match policy {
SandboxPolicy::DangerFullAccess => {
Some(codex_app_server_protocol::SandboxMode::DangerFullAccess)
}
SandboxPolicy::ReadOnly { .. } => Some(codex_app_server_protocol::SandboxMode::ReadOnly),
SandboxPolicy::WorkspaceWrite { .. } => {
Some(codex_app_server_protocol::SandboxMode::WorkspaceWrite)
}
SandboxPolicy::ExternalSandbox { .. } => None,
}
}
fn thread_start_params_from_config(
config: &Config,
thread_params_mode: ThreadParamsMode,
) -> ThreadStartParams {
ThreadStartParams {
model: config.model.clone(),
model_provider: thread_params_mode.model_provider_from_config(config),
cwd: thread_cwd_from_config(config, thread_params_mode),
approval_policy: Some(config.permissions.approval_policy.value().into()),
approvals_reviewer: approvals_reviewer_override_from_config(config),
sandbox: sandbox_mode_from_policy(config.permissions.sandbox_policy.get().clone()),
config: config_request_overrides_from_config(config),
ephemeral: Some(config.ephemeral),
persist_extended_history: true,
..ThreadStartParams::default()
}
}
fn thread_resume_params_from_config(
config: Config,
thread_id: ThreadId,
thread_params_mode: ThreadParamsMode,
) -> ThreadResumeParams {
ThreadResumeParams {
thread_id: thread_id.to_string(),
model: config.model.clone(),
model_provider: thread_params_mode.model_provider_from_config(&config),
cwd: thread_cwd_from_config(&config, thread_params_mode),
approval_policy: Some(config.permissions.approval_policy.value().into()),
approvals_reviewer: approvals_reviewer_override_from_config(&config),
sandbox: sandbox_mode_from_policy(config.permissions.sandbox_policy.get().clone()),
config: config_request_overrides_from_config(&config),
persist_extended_history: true,
..ThreadResumeParams::default()
}
}
fn thread_fork_params_from_config(
config: Config,
thread_id: ThreadId,
thread_params_mode: ThreadParamsMode,
) -> ThreadForkParams {
ThreadForkParams {
thread_id: thread_id.to_string(),
model: config.model.clone(),
model_provider: thread_params_mode.model_provider_from_config(&config),
cwd: thread_cwd_from_config(&config, thread_params_mode),
approval_policy: Some(config.permissions.approval_policy.value().into()),
approvals_reviewer: approvals_reviewer_override_from_config(&config),
sandbox: sandbox_mode_from_policy(config.permissions.sandbox_policy.get().clone()),
config: config_request_overrides_from_config(&config),
ephemeral: config.ephemeral,
persist_extended_history: true,
..ThreadForkParams::default()
}
}
fn thread_cwd_from_config(config: &Config, thread_params_mode: ThreadParamsMode) -> Option<String> {
match thread_params_mode {
ThreadParamsMode::Embedded => Some(config.cwd.to_string_lossy().to_string()),
ThreadParamsMode::Remote => None,
}
}
fn started_thread_from_start_response(
response: &ThreadStartResponse,
) -> Result<AppServerStartedThread> {
let session_configured = session_configured_from_thread_start_response(response)
.map_err(color_eyre::eyre::Report::msg)?;
Ok(AppServerStartedThread { session_configured })
}
fn started_thread_from_resume_response(
response: &ThreadResumeResponse,
show_raw_agent_reasoning: bool,
) -> Result<AppServerStartedThread> {
let session_configured = session_configured_from_thread_resume_response(response)
.map_err(color_eyre::eyre::Report::msg)?;
Ok(AppServerStartedThread {
session_configured: SessionConfiguredEvent {
initial_messages: thread_initial_messages(
&session_configured.session_id,
&response.thread.turns,
show_raw_agent_reasoning,
),
..session_configured
},
})
}
fn started_thread_from_fork_response(
response: &ThreadForkResponse,
show_raw_agent_reasoning: bool,
) -> Result<AppServerStartedThread> {
let session_configured = session_configured_from_thread_fork_response(response)
.map_err(color_eyre::eyre::Report::msg)?;
Ok(AppServerStartedThread {
session_configured: SessionConfiguredEvent {
initial_messages: thread_initial_messages(
&session_configured.session_id,
&response.thread.turns,
show_raw_agent_reasoning,
),
..session_configured
},
})
}
fn session_configured_from_thread_start_response(
response: &ThreadStartResponse,
) -> Result<SessionConfiguredEvent, String> {
session_configured_from_thread_response(
&response.thread.id,
response.thread.name.clone(),
response.thread.path.clone(),
response.model.clone(),
response.model_provider.clone(),
response.service_tier,
response.approval_policy.to_core(),
response.approvals_reviewer.to_core(),
response.sandbox.to_core(),
response.cwd.clone(),
response.reasoning_effort,
)
}
fn session_configured_from_thread_resume_response(
response: &ThreadResumeResponse,
) -> Result<SessionConfiguredEvent, String> {
session_configured_from_thread_response(
&response.thread.id,
response.thread.name.clone(),
response.thread.path.clone(),
response.model.clone(),
response.model_provider.clone(),
response.service_tier,
response.approval_policy.to_core(),
response.approvals_reviewer.to_core(),
response.sandbox.to_core(),
response.cwd.clone(),
response.reasoning_effort,
)
}
fn session_configured_from_thread_fork_response(
response: &ThreadForkResponse,
) -> Result<SessionConfiguredEvent, String> {
session_configured_from_thread_response(
&response.thread.id,
response.thread.name.clone(),
response.thread.path.clone(),
response.model.clone(),
response.model_provider.clone(),
response.service_tier,
response.approval_policy.to_core(),
response.approvals_reviewer.to_core(),
response.sandbox.to_core(),
response.cwd.clone(),
response.reasoning_effort,
)
}
fn review_target_to_app_server(
target: CoreReviewTarget,
) -> codex_app_server_protocol::ReviewTarget {
match target {
CoreReviewTarget::UncommittedChanges => {
codex_app_server_protocol::ReviewTarget::UncommittedChanges
}
CoreReviewTarget::BaseBranch { branch } => {
codex_app_server_protocol::ReviewTarget::BaseBranch { branch }
}
CoreReviewTarget::Commit { sha, title } => {
codex_app_server_protocol::ReviewTarget::Commit { sha, title }
}
CoreReviewTarget::Custom { instructions } => {
codex_app_server_protocol::ReviewTarget::Custom { instructions }
}
}
}
#[expect(
clippy::too_many_arguments,
reason = "session mapping keeps explicit fields"
)]
fn session_configured_from_thread_response(
thread_id: &str,
thread_name: Option<String>,
rollout_path: Option<PathBuf>,
model: String,
model_provider_id: String,
service_tier: Option<codex_protocol::config_types::ServiceTier>,
approval_policy: AskForApproval,
approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer,
sandbox_policy: SandboxPolicy,
cwd: PathBuf,
reasoning_effort: Option<codex_protocol::openai_models::ReasoningEffort>,
) -> Result<SessionConfiguredEvent, String> {
let session_id = ThreadId::from_string(thread_id)
.map_err(|err| format!("thread id `{thread_id}` is invalid: {err}"))?;
Ok(SessionConfiguredEvent {
session_id,
forked_from_id: None,
thread_name,
model,
model_provider_id,
service_tier,
approval_policy,
approvals_reviewer,
sandbox_policy,
cwd,
reasoning_effort,
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path,
})
}
fn thread_initial_messages(
thread_id: &ThreadId,
turns: &[codex_app_server_protocol::Turn],
show_raw_agent_reasoning: bool,
) -> Option<Vec<EventMsg>> {
let events: Vec<EventMsg> = turns
.iter()
.flat_map(|turn| turn_initial_messages(thread_id, turn, show_raw_agent_reasoning))
.collect();
(!events.is_empty()).then_some(events)
}
fn turn_initial_messages(
thread_id: &ThreadId,
turn: &codex_app_server_protocol::Turn,
show_raw_agent_reasoning: bool,
) -> Vec<EventMsg> {
turn.items
.iter()
.cloned()
.filter_map(app_server_thread_item_to_core)
.flat_map(|item| match item {
TurnItem::UserMessage(item) => vec![item.as_legacy_event()],
TurnItem::Plan(item) => vec![EventMsg::ItemCompleted(ItemCompletedEvent {
thread_id: *thread_id,
turn_id: turn.id.clone(),
item: TurnItem::Plan(item),
})],
item => item.as_legacy_events(show_raw_agent_reasoning),
})
.collect()
}
fn app_server_thread_item_to_core(item: codex_app_server_protocol::ThreadItem) -> Option<TurnItem> {
match item {
codex_app_server_protocol::ThreadItem::UserMessage { id, content } => {
Some(TurnItem::UserMessage(UserMessageItem {
id,
content: content
.into_iter()
.map(codex_app_server_protocol::UserInput::into_core)
.collect(),
}))
}
codex_app_server_protocol::ThreadItem::AgentMessage { id, text, phase } => {
Some(TurnItem::AgentMessage(AgentMessageItem {
id,
content: vec![AgentMessageContent::Text { text }],
phase,
}))
}
codex_app_server_protocol::ThreadItem::Plan { id, text } => {
Some(TurnItem::Plan(PlanItem { id, text }))
}
codex_app_server_protocol::ThreadItem::Reasoning {
id,
summary,
content,
} => Some(TurnItem::Reasoning(ReasoningItem {
id,
summary_text: summary,
raw_content: content,
})),
codex_app_server_protocol::ThreadItem::WebSearch { id, query, action } => {
Some(TurnItem::WebSearch(WebSearchItem {
id,
query,
action: app_server_web_search_action_to_core(action?)?,
}))
}
codex_app_server_protocol::ThreadItem::ImageGeneration {
id,
status,
revised_prompt,
result,
} => Some(TurnItem::ImageGeneration(ImageGenerationItem {
id,
status,
revised_prompt,
result,
saved_path: None,
})),
codex_app_server_protocol::ThreadItem::ContextCompaction { id } => {
Some(TurnItem::ContextCompaction(ContextCompactionItem { id }))
}
codex_app_server_protocol::ThreadItem::CommandExecution { .. }
| codex_app_server_protocol::ThreadItem::FileChange { .. }
| codex_app_server_protocol::ThreadItem::McpToolCall { .. }
| codex_app_server_protocol::ThreadItem::DynamicToolCall { .. }
| codex_app_server_protocol::ThreadItem::CollabAgentToolCall { .. }
| codex_app_server_protocol::ThreadItem::ImageView { .. }
| codex_app_server_protocol::ThreadItem::EnteredReviewMode { .. }
| codex_app_server_protocol::ThreadItem::ExitedReviewMode { .. } => None,
}
}
fn app_server_web_search_action_to_core(
action: codex_app_server_protocol::WebSearchAction,
) -> Option<codex_protocol::models::WebSearchAction> {
match action {
codex_app_server_protocol::WebSearchAction::Search { query, queries } => {
Some(codex_protocol::models::WebSearchAction::Search { query, queries })
}
codex_app_server_protocol::WebSearchAction::OpenPage { url } => {
Some(codex_protocol::models::WebSearchAction::OpenPage { url })
}
codex_app_server_protocol::WebSearchAction::FindInPage { url, pattern } => {
Some(codex_protocol::models::WebSearchAction::FindInPage { url, pattern })
}
codex_app_server_protocol::WebSearchAction::Other => {
Some(codex_protocol::models::WebSearchAction::Other)
}
}
}
fn app_server_rate_limit_snapshots_to_core(
response: GetAccountRateLimitsResponse,
) -> Vec<RateLimitSnapshot> {
let mut snapshots = Vec::new();
snapshots.push(app_server_rate_limit_snapshot_to_core(response.rate_limits));
if let Some(by_limit_id) = response.rate_limits_by_limit_id {
snapshots.extend(
by_limit_id
.into_values()
.map(app_server_rate_limit_snapshot_to_core),
);
}
snapshots
}
pub(crate) fn app_server_rate_limit_snapshot_to_core(
snapshot: codex_app_server_protocol::RateLimitSnapshot,
) -> RateLimitSnapshot {
RateLimitSnapshot {
limit_id: snapshot.limit_id,
limit_name: snapshot.limit_name,
primary: snapshot.primary.map(app_server_rate_limit_window_to_core),
secondary: snapshot.secondary.map(app_server_rate_limit_window_to_core),
credits: snapshot.credits.map(app_server_credits_snapshot_to_core),
plan_type: snapshot.plan_type,
}
}
fn app_server_rate_limit_window_to_core(
window: codex_app_server_protocol::RateLimitWindow,
) -> RateLimitWindow {
RateLimitWindow {
used_percent: window.used_percent as f64,
window_minutes: window.window_duration_mins,
resets_at: window.resets_at,
}
}
fn app_server_credits_snapshot_to_core(
snapshot: codex_app_server_protocol::CreditsSnapshot,
) -> CreditsSnapshot {
CreditsSnapshot {
has_credits: snapshot.has_credits,
unlimited: snapshot.unlimited,
balance: snapshot.balance,
}
}
#[cfg(test)]
mod tests {
use super::*;
use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnStatus;
use codex_core::config::ConfigBuilder;
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 = build_config(&temp_dir).await;
let params = thread_start_params_from_config(&config, ThreadParamsMode::Embedded);
assert_eq!(params.cwd, Some(config.cwd.to_string_lossy().to_string()));
assert_eq!(params.model_provider, Some(config.model_provider_id));
}
#[tokio::test]
async fn thread_lifecycle_params_omit_local_overrides_for_remote_sessions() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let config = build_config(&temp_dir).await;
let thread_id = ThreadId::new();
let start = thread_start_params_from_config(&config, ThreadParamsMode::Remote);
let resume =
thread_resume_params_from_config(config.clone(), thread_id, ThreadParamsMode::Remote);
let fork = thread_fork_params_from_config(config, thread_id, ThreadParamsMode::Remote);
assert_eq!(start.cwd, None);
assert_eq!(resume.cwd, None);
assert_eq!(fork.cwd, None);
assert_eq!(start.model_provider, None);
assert_eq!(resume.model_provider, None);
assert_eq!(fork.model_provider, None);
}
#[test]
fn resume_response_restores_initial_messages_from_turn_items() {
let thread_id = ThreadId::new();
let response = ThreadResumeResponse {
thread: codex_app_server_protocol::Thread {
id: thread_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: PathBuf::from("/tmp/project"),
cli_version: "0.0.0".to_string(),
source: codex_protocol::protocol::SessionSource::Cli.into(),
agent_nickname: None,
agent_role: None,
git_info: None,
name: None,
turns: vec![Turn {
id: "turn-1".to_string(),
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,
},
],
status: TurnStatus::Completed,
error: None,
}],
},
model: "gpt-5.4".to_string(),
model_provider: "openai".to_string(),
service_tier: None,
cwd: PathBuf::from("/tmp/project"),
approval_policy: codex_protocol::protocol::AskForApproval::Never.into(),
approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::User,
sandbox: codex_protocol::protocol::SandboxPolicy::new_read_only_policy().into(),
reasoning_effort: None,
};
let started =
started_thread_from_resume_response(&response, /*show_raw_agent_reasoning*/ false)
.expect("resume response should map");
let initial_messages = started
.session_configured
.initial_messages
.expect("resume response should restore replay history");
assert_eq!(initial_messages.len(), 2);
match &initial_messages[0] {
EventMsg::UserMessage(event) => {
assert_eq!(event.message, "hello from history");
assert_eq!(event.images.as_ref(), Some(&Vec::new()));
assert!(event.local_images.is_empty());
assert!(event.text_elements.is_empty());
}
other => panic!("expected replayed user message, got {other:?}"),
}
match &initial_messages[1] {
EventMsg::AgentMessage(event) => {
assert_eq!(event.message, "assistant reply");
assert_eq!(event.phase, None);
}
other => panic!("expected replayed agent message, got {other:?}"),
}
}
}